Remove speed control
This commit is contained in:
110
public/app.js
110
public/app.js
@@ -10,8 +10,6 @@
|
|||||||
let joinFile = null; // File object for the joiner
|
let joinFile = null; // File object for the joiner
|
||||||
let ignoreSync = false; // flag to avoid feedback loops
|
let ignoreSync = false; // flag to avoid feedback loops
|
||||||
let syncTimeout = null;
|
let syncTimeout = null;
|
||||||
let driftInterval = null;
|
|
||||||
let serverState = { playing: false, position: 0, speed: 1 };
|
|
||||||
|
|
||||||
// Reconnection state
|
// Reconnection state
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
@@ -73,7 +71,6 @@
|
|||||||
const volumeSlider = $("volume-slider");
|
const volumeSlider = $("volume-slider");
|
||||||
const currentTimeEl = $("current-time");
|
const currentTimeEl = $("current-time");
|
||||||
const durationEl = $("duration");
|
const durationEl = $("duration");
|
||||||
const speedSelect = $("speed-select");
|
|
||||||
const fullscreenBtn = $("fullscreen-btn");
|
const fullscreenBtn = $("fullscreen-btn");
|
||||||
const userCountEl = $("user-count");
|
const userCountEl = $("user-count");
|
||||||
const usersList = $("users-list");
|
const usersList = $("users-list");
|
||||||
@@ -243,7 +240,6 @@
|
|||||||
applySync(msg.state);
|
applySync(msg.state);
|
||||||
}
|
}
|
||||||
addSystemMessage("Reconnected");
|
addSystemMessage("Reconnected");
|
||||||
startDriftCorrection();
|
|
||||||
flushMessageQueue();
|
flushMessageQueue();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -298,82 +294,22 @@
|
|||||||
// Load local file into video player
|
// Load local file into video player
|
||||||
const file = localFile || joinFile;
|
const file = localFile || joinFile;
|
||||||
if (file) {
|
if (file) {
|
||||||
loadVideoSource(file);
|
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
|
||||||
|
currentBlobUrl = URL.createObjectURL(file);
|
||||||
|
videoPlayer.src = currentBlobUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handle false "ended" events (Chromium MKV blob bug) ---
|
|
||||||
// Chrome can't properly index MKV containers loaded via blob URLs.
|
|
||||||
// After ~30s it may jump to the end and fire "ended" even though
|
|
||||||
// the video isn't actually over. Recovery: reload the blob and seek.
|
|
||||||
let recoveryAttempts = 0;
|
|
||||||
const MAX_RECOVERY_ATTEMPTS = 5;
|
|
||||||
|
|
||||||
videoPlayer.addEventListener("ended", () => {
|
|
||||||
const duration = videoPlayer.duration || 0;
|
|
||||||
// Only attempt recovery if we have server state showing we're not near the end
|
|
||||||
if (duration > 0 && lastServerState.position < duration - 5 && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
|
|
||||||
recoveryAttempts++;
|
|
||||||
console.log(`[MKV-RECOVERY] False ended detected. Server pos=${lastServerState.position.toFixed(1)}, duration=${duration.toFixed(1)}. Reloading source (attempt ${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})`);
|
|
||||||
|
|
||||||
const targetPos = lastServerState.position;
|
|
||||||
const wasPlaying = lastServerState.playing;
|
|
||||||
const currentFile = localFile || joinFile;
|
|
||||||
|
|
||||||
if (currentFile) {
|
|
||||||
// Reload the video with a fresh blob URL
|
|
||||||
loadVideoSource(currentFile, targetPos, wasPlaying);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset recovery counter when video plays successfully for a while
|
|
||||||
let recoveryResetTimer = null;
|
|
||||||
videoPlayer.addEventListener("timeupdate", () => {
|
|
||||||
if (recoveryAttempts > 0) {
|
|
||||||
clearTimeout(recoveryResetTimer);
|
|
||||||
recoveryResetTimer = setTimeout(() => {
|
|
||||||
recoveryAttempts = 0;
|
|
||||||
}, 10000); // Reset after 10s of successful playback
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Resync on tab focus (handles background tab throttling) ---
|
// --- Resync on tab focus (handles background tab throttling) ---
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!document.hidden && roomCode && ws && ws.readyState === WebSocket.OPEN) {
|
if (!document.hidden && roomCode && ws && ws.readyState === WebSocket.OPEN) {
|
||||||
console.log("[SYNC] Tab became visible, requesting state resync");
|
|
||||||
send({ type: "request_state" });
|
send({ type: "request_state" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start drift correction
|
|
||||||
startDriftCorrection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Video Source Loading ---
|
// --- State tracking ---
|
||||||
let currentBlobUrl = null;
|
let currentBlobUrl = null;
|
||||||
let lastServerState = { playing: false, position: 0, speed: 1 };
|
let lastServerState = { playing: false, position: 0 };
|
||||||
|
|
||||||
function loadVideoSource(file, seekTo, shouldPlay) {
|
|
||||||
// Revoke old blob URL to free memory
|
|
||||||
if (currentBlobUrl) {
|
|
||||||
URL.revokeObjectURL(currentBlobUrl);
|
|
||||||
}
|
|
||||||
currentBlobUrl = URL.createObjectURL(file);
|
|
||||||
videoPlayer.src = currentBlobUrl;
|
|
||||||
|
|
||||||
if (seekTo !== undefined) {
|
|
||||||
videoPlayer.addEventListener("loadedmetadata", function onMeta() {
|
|
||||||
videoPlayer.removeEventListener("loadedmetadata", onMeta);
|
|
||||||
videoPlayer.currentTime = seekTo;
|
|
||||||
if (shouldPlay) {
|
|
||||||
videoPlayer.play().then(() => {
|
|
||||||
videoWrapper.classList.add("playing");
|
|
||||||
updatePlayPauseIcon();
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- File Check Modal ---
|
// --- File Check Modal ---
|
||||||
let pendingRoomFileInfo = null;
|
let pendingRoomFileInfo = null;
|
||||||
@@ -459,12 +395,7 @@
|
|||||||
// Track latest server state for recovery
|
// Track latest server state for recovery
|
||||||
if (data.position !== undefined) lastServerState.position = data.position;
|
if (data.position !== undefined) lastServerState.position = data.position;
|
||||||
if (data.playing !== undefined) lastServerState.playing = data.playing;
|
if (data.playing !== undefined) lastServerState.playing = data.playing;
|
||||||
if (data.speed !== undefined) lastServerState.speed = data.speed;
|
|
||||||
|
|
||||||
if (data.speed !== undefined && videoPlayer.playbackRate !== data.speed) {
|
|
||||||
videoPlayer.playbackRate = data.speed;
|
|
||||||
speedSelect.value = String(data.speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.position !== undefined) {
|
if (data.position !== undefined) {
|
||||||
const diff = Math.abs(videoPlayer.currentTime - data.position);
|
const diff = Math.abs(videoPlayer.currentTime - data.position);
|
||||||
@@ -510,14 +441,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDriftCorrection() {
|
|
||||||
if (driftInterval) clearInterval(driftInterval);
|
|
||||||
driftInterval = setInterval(() => {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
send({ type: "request_state" });
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Get video duration from a file (used for file info) ---
|
// --- Get video duration from a file (used for file info) ---
|
||||||
function getVideoDuration(file) {
|
function getVideoDuration(file) {
|
||||||
@@ -544,6 +468,17 @@
|
|||||||
container.classList.remove("hidden");
|
container.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMkvFile(file) {
|
||||||
|
return file.name.toLowerCase().endsWith(".mkv");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFormatWarning(container) {
|
||||||
|
const warning = document.createElement("div");
|
||||||
|
warning.className = "format-warning";
|
||||||
|
warning.innerHTML = `⚠️ <strong>MKV files may not play correctly</strong> in browsers. Convert to MP4 for best results:<br><code>ffmpeg -i file.mkv -c copy file.mp4</code>`;
|
||||||
|
container.parentNode.insertBefore(warning, container.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== EVENT LISTENERS =====
|
// ===== EVENT LISTENERS =====
|
||||||
|
|
||||||
// --- Lobby: Username ---
|
// --- Lobby: Username ---
|
||||||
@@ -560,7 +495,11 @@
|
|||||||
localFile = file;
|
localFile = file;
|
||||||
const duration = await getVideoDuration(file);
|
const duration = await getVideoDuration(file);
|
||||||
localFile._duration = duration;
|
localFile._duration = duration;
|
||||||
|
// Remove any previous format warning
|
||||||
|
const oldWarning = createFileInfo.parentNode.querySelector(".format-warning");
|
||||||
|
if (oldWarning) oldWarning.remove();
|
||||||
renderFileInfo(createFileInfo, file, duration);
|
renderFileInfo(createFileInfo, file, duration);
|
||||||
|
if (isMkvFile(file)) showFormatWarning(createFileInfo);
|
||||||
createRoomBtn.disabled = !usernameInput.value.trim();
|
createRoomBtn.disabled = !usernameInput.value.trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -679,7 +618,6 @@
|
|||||||
joinFile = null;
|
joinFile = null;
|
||||||
roomFileInfo = null;
|
roomFileInfo = null;
|
||||||
messageQueue = [];
|
messageQueue = [];
|
||||||
if (driftInterval) clearInterval(driftInterval);
|
|
||||||
videoPlayer.src = "";
|
videoPlayer.src = "";
|
||||||
chatMessages.innerHTML = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
|
chatMessages.innerHTML = '<div class="chat-welcome" id="chat-welcome"><p>Welcome to the room! 👋</p></div>';
|
||||||
createFileInfo.classList.add("hidden");
|
createFileInfo.classList.add("hidden");
|
||||||
@@ -766,12 +704,6 @@
|
|||||||
volOffIcon.classList.toggle("hidden", !videoPlayer.muted);
|
volOffIcon.classList.toggle("hidden", !videoPlayer.muted);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Speed
|
|
||||||
speedSelect.addEventListener("change", () => {
|
|
||||||
const speed = parseFloat(speedSelect.value);
|
|
||||||
videoPlayer.playbackRate = speed;
|
|
||||||
send({ type: "sync", action: "speed", speed });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fullscreen
|
// Fullscreen
|
||||||
fullscreenBtn.addEventListener("click", () => {
|
fullscreenBtn.addEventListener("click", () => {
|
||||||
|
|||||||
@@ -158,15 +158,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-right">
|
<div class="controls-right">
|
||||||
<select id="speed-select" class="speed-select" title="Playback speed">
|
|
||||||
<option value="0.25">0.25x</option>
|
|
||||||
<option value="0.5">0.5x</option>
|
|
||||||
<option value="0.75">0.75x</option>
|
|
||||||
<option value="1" selected>1x</option>
|
|
||||||
<option value="1.25">1.25x</option>
|
|
||||||
<option value="1.5">1.5x</option>
|
|
||||||
<option value="2">2x</option>
|
|
||||||
</select>
|
|
||||||
<button id="fullscreen-btn" class="ctrl-btn" title="Fullscreen">
|
<button id="fullscreen-btn" class="ctrl-btn" title="Fullscreen">
|
||||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="white">
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="white">
|
||||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||||
|
|||||||
@@ -282,6 +282,30 @@ select {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Format warning (MKV etc.) */
|
||||||
|
.format-warning {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(243, 156, 18, 0.1);
|
||||||
|
border: 1px solid rgba(243, 156, 18, 0.3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-warning code {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
12
server.ts
12
server.ts
@@ -13,7 +13,6 @@ interface RoomState {
|
|||||||
fileInfo: FileInfo;
|
fileInfo: FileInfo;
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
speed: number;
|
|
||||||
lastUpdate: number; // timestamp when position was last set
|
lastUpdate: number; // timestamp when position was last set
|
||||||
users: Map<string, WebSocket>;
|
users: Map<string, WebSocket>;
|
||||||
chatHistory: ChatMessage[];
|
chatHistory: ChatMessage[];
|
||||||
@@ -41,7 +40,7 @@ function generateRoomCode(): string {
|
|||||||
function getCurrentPosition(room: RoomState): number {
|
function getCurrentPosition(room: RoomState): number {
|
||||||
if (!room.playing) return room.position;
|
if (!room.playing) return room.position;
|
||||||
const elapsed = (Date.now() - room.lastUpdate) / 1000;
|
const elapsed = (Date.now() - room.lastUpdate) / 1000;
|
||||||
return room.position + elapsed * room.speed;
|
return room.position + elapsed; // assume 1x speed
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastToRoom(room: RoomState, message: object, excludeWs?: WebSocket) {
|
function broadcastToRoom(room: RoomState, message: object, excludeWs?: WebSocket) {
|
||||||
@@ -159,7 +158,6 @@ const server = Bun.serve<WSData>({
|
|||||||
},
|
},
|
||||||
playing: false,
|
playing: false,
|
||||||
position: 0,
|
position: 0,
|
||||||
speed: 1,
|
|
||||||
lastUpdate: Date.now(),
|
lastUpdate: Date.now(),
|
||||||
users: new Map(),
|
users: new Map(),
|
||||||
chatHistory: [],
|
chatHistory: [],
|
||||||
@@ -249,7 +247,6 @@ const server = Bun.serve<WSData>({
|
|||||||
state: {
|
state: {
|
||||||
playing: room.playing,
|
playing: room.playing,
|
||||||
position: getCurrentPosition(room),
|
position: getCurrentPosition(room),
|
||||||
speed: room.speed,
|
|
||||||
},
|
},
|
||||||
chatHistory: room.chatHistory.slice(-50),
|
chatHistory: room.chatHistory.slice(-50),
|
||||||
})
|
})
|
||||||
@@ -285,10 +282,6 @@ const server = Bun.serve<WSData>({
|
|||||||
} else if (msg.action === "seek") {
|
} else if (msg.action === "seek") {
|
||||||
room.position = msg.position;
|
room.position = msg.position;
|
||||||
room.lastUpdate = Date.now();
|
room.lastUpdate = Date.now();
|
||||||
} else if (msg.action === "speed") {
|
|
||||||
room.position = getCurrentPosition(room);
|
|
||||||
room.speed = msg.speed;
|
|
||||||
room.lastUpdate = Date.now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to others
|
// Broadcast to others
|
||||||
@@ -299,7 +292,6 @@ const server = Bun.serve<WSData>({
|
|||||||
action: msg.action,
|
action: msg.action,
|
||||||
position: room.position,
|
position: room.position,
|
||||||
playing: room.playing,
|
playing: room.playing,
|
||||||
speed: room.speed,
|
|
||||||
username: ws.data.username,
|
username: ws.data.username,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
@@ -343,7 +335,6 @@ const server = Bun.serve<WSData>({
|
|||||||
state: {
|
state: {
|
||||||
playing: room.playing,
|
playing: room.playing,
|
||||||
position: getCurrentPosition(room),
|
position: getCurrentPosition(room),
|
||||||
speed: room.speed,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -388,7 +379,6 @@ const server = Bun.serve<WSData>({
|
|||||||
state: {
|
state: {
|
||||||
playing: room.playing,
|
playing: room.playing,
|
||||||
position: getCurrentPosition(room),
|
position: getCurrentPosition(room),
|
||||||
speed: room.speed,
|
|
||||||
},
|
},
|
||||||
chatHistory: room.chatHistory.slice(-50),
|
chatHistory: room.chatHistory.slice(-50),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user