Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

View file

@ -0,0 +1,515 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
const STORAGE_KEY = 'audioPlaylist';
// Simple debounce utility
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
const defaultState = {
enabled: false, // Player visibility
queue: [], // Array of {id, title, username, filePath, thumbnailPath, durationSeconds, realmName}
currentIndex: 0,
nowPlaying: null, // Track playing outside of queue (for instant play without adding to queue)
isPlaying: false,
shouldPlay: false, // Signal to start playback (used to trigger play after load)
volume: 1.0,
muted: false,
shuffle: false,
repeat: 'none', // 'none', 'one', 'all'
currentTime: 0,
duration: 0,
minimized: false // Mini player mode
};
function loadState() {
if (!browser) return defaultState;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Reset playback state on load (don't auto-resume)
return {
...defaultState,
...parsed,
nowPlaying: null,
isPlaying: false,
shouldPlay: false,
currentTime: 0,
duration: 0
};
}
} catch (e) {
console.error('Failed to load audio playlist:', e);
}
return defaultState;
}
function saveState(state) {
if (!browser) return;
try {
// Only persist certain fields
const toSave = {
enabled: state.enabled,
queue: state.queue,
currentIndex: state.currentIndex,
volume: state.volume,
muted: state.muted,
shuffle: state.shuffle,
repeat: state.repeat,
minimized: state.minimized
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {
console.error('Failed to save audio playlist:', e);
}
}
// Debounced version to prevent excessive writes during rapid state changes
const debouncedSaveState = debounce(saveState, 300);
function createAudioPlaylistStore() {
const { subscribe, set, update } = writable(loadState());
// Helper to update and save (debounced to reduce localStorage writes)
const updateAndSave = (fn) => {
update(state => {
const newState = fn(state);
debouncedSaveState(newState);
return newState;
});
};
return {
subscribe,
// Add a track to the end of the queue
addTrack(track) {
updateAndSave(state => {
const exists = state.queue.some(t => t.id === track.id);
if (exists) return state;
return {
...state,
enabled: true,
queue: [...state.queue, track]
};
});
},
// Play track immediately without adding to queue (sets nowPlaying)
playTrack(track) {
update(state => ({
...state,
enabled: true,
nowPlaying: track,
isPlaying: false, // Will be set true when audio loads
shouldPlay: true, // Signal to start playback
currentTime: 0,
duration: 0
}));
},
// Add track to queue AND play it (for queue-based playback)
playFromQueue(track) {
updateAndSave(state => {
const existingIndex = state.queue.findIndex(t => t.id === track.id);
if (existingIndex >= 0) {
// Track exists in queue, switch to it and clear nowPlaying
return {
...state,
enabled: true,
nowPlaying: null,
currentIndex: existingIndex,
isPlaying: false,
shouldPlay: true,
currentTime: 0
};
}
// Add track to queue and play it
return {
...state,
enabled: true,
nowPlaying: null,
queue: [...state.queue, track],
currentIndex: state.queue.length,
isPlaying: false,
shouldPlay: true,
currentTime: 0
};
});
},
// Add multiple tracks
addTracks(tracks) {
updateAndSave(state => {
const newTracks = tracks.filter(t => !state.queue.some(q => q.id === t.id));
if (newTracks.length === 0) return state;
return {
...state,
enabled: true,
queue: [...state.queue, ...newTracks]
};
});
},
// Remove a track from the queue
removeTrack(trackId) {
updateAndSave(state => {
const index = state.queue.findIndex(t => t.id === trackId);
if (index === -1) return state;
const newQueue = state.queue.filter(t => t.id !== trackId);
let newIndex = state.currentIndex;
let shouldTriggerNext = false;
// Adjust current index if needed
if (index < state.currentIndex) {
newIndex = state.currentIndex - 1;
} else if (index === state.currentIndex) {
// Currently playing track was removed
if (newQueue.length === 0) {
return { ...defaultState };
}
newIndex = Math.min(state.currentIndex, newQueue.length - 1);
shouldTriggerNext = true;
}
return {
...state,
queue: newQueue,
currentIndex: newIndex,
enabled: newQueue.length > 0,
// Reset playback state and trigger new track to load
isPlaying: shouldTriggerNext ? false : state.isPlaying,
shouldPlay: shouldTriggerNext,
currentTime: shouldTriggerNext ? 0 : state.currentTime
};
});
},
// Clear the entire queue (but keep currently playing track as nowPlaying)
clearQueue() {
updateAndSave(state => {
// If something is playing, move it to nowPlaying so it keeps playing
const currentlyPlaying = state.isPlaying ?
(state.nowPlaying || state.queue[state.currentIndex]) : null;
if (currentlyPlaying) {
return {
...defaultState,
enabled: true,
nowPlaying: currentlyPlaying,
isPlaying: state.isPlaying,
currentTime: state.currentTime,
duration: state.duration,
volume: state.volume,
muted: state.muted
};
}
return { ...defaultState };
});
},
// Play/pause toggle
togglePlay() {
update(state => ({
...state,
isPlaying: !state.isPlaying
}));
},
// Set playing state
setPlaying(playing) {
update(state => ({
...state,
isPlaying: playing
}));
},
// Go to next track
next() {
updateAndSave(state => {
// If nowPlaying, clear it and go to queue (or stop)
if (state.nowPlaying) {
if (state.queue.length > 0) {
return {
...state,
nowPlaying: null,
currentIndex: 0,
currentTime: 0,
isPlaying: false,
shouldPlay: true
};
}
return { ...state, nowPlaying: null, isPlaying: false, shouldPlay: false };
}
if (state.queue.length === 0) return state;
let nextIndex;
if (state.shuffle) {
// Random next (avoid current if possible)
if (state.queue.length === 1) {
nextIndex = 0;
} else {
do {
nextIndex = Math.floor(Math.random() * state.queue.length);
} while (nextIndex === state.currentIndex);
}
} else {
nextIndex = state.currentIndex + 1;
if (nextIndex >= state.queue.length) {
if (state.repeat === 'all') {
nextIndex = 0;
} else {
// Stop at end
return { ...state, isPlaying: false, shouldPlay: false };
}
}
}
return {
...state,
currentIndex: nextIndex,
currentTime: 0,
isPlaying: false,
shouldPlay: true
};
});
},
// Go to previous track
previous() {
updateAndSave(state => {
// If more than 3 seconds in, restart current track
if (state.currentTime > 3) {
return { ...state, currentTime: 0 };
}
// If nowPlaying, restart it or go to end of queue
if (state.nowPlaying) {
if (state.queue.length > 0) {
return {
...state,
nowPlaying: null,
currentIndex: state.queue.length - 1,
currentTime: 0,
isPlaying: false,
shouldPlay: true
};
}
return { ...state, currentTime: 0 };
}
if (state.queue.length === 0) return state;
let prevIndex;
if (state.shuffle) {
// Random previous
if (state.queue.length === 1) {
prevIndex = 0;
} else {
do {
prevIndex = Math.floor(Math.random() * state.queue.length);
} while (prevIndex === state.currentIndex);
}
} else {
prevIndex = state.currentIndex - 1;
if (prevIndex < 0) {
if (state.repeat === 'all') {
prevIndex = state.queue.length - 1;
} else {
prevIndex = 0;
}
}
}
return {
...state,
currentIndex: prevIndex,
currentTime: 0,
isPlaying: false,
shouldPlay: true
};
});
},
// Jump to specific track in queue
goToTrack(index) {
updateAndSave(state => {
if (index < 0 || index >= state.queue.length) return state;
return {
...state,
nowPlaying: null, // Clear nowPlaying when jumping to queue
currentIndex: index,
currentTime: 0,
isPlaying: false,
shouldPlay: true
};
});
},
// Set volume (0-1)
setVolume(volume) {
updateAndSave(state => ({
...state,
volume: Math.max(0, Math.min(1, volume)),
muted: volume === 0 ? true : state.muted
}));
},
// Toggle mute
toggleMute() {
updateAndSave(state => ({
...state,
muted: !state.muted
}));
},
// Toggle shuffle
toggleShuffle() {
updateAndSave(state => ({
...state,
shuffle: !state.shuffle
}));
},
// Cycle repeat mode
cycleRepeat() {
updateAndSave(state => {
const modes = ['none', 'all', 'one'];
const currentIdx = modes.indexOf(state.repeat);
const nextMode = modes[(currentIdx + 1) % modes.length];
return { ...state, repeat: nextMode };
});
},
// Update current time (from audio element)
updateTime(currentTime, duration) {
update(state => ({
...state,
currentTime,
duration
}));
},
// Seek to time
seek(time) {
update(state => ({
...state,
currentTime: time
}));
},
// Toggle minimized state
toggleMinimized() {
updateAndSave(state => ({
...state,
minimized: !state.minimized
}));
},
// Show player
show() {
updateAndSave(state => ({
...state,
enabled: true
}));
},
// Hide player
hide() {
updateAndSave(state => ({
...state,
enabled: false,
isPlaying: false
}));
},
// Move track in queue (reorder)
moveTrack(fromIndex, toIndex) {
updateAndSave(state => {
if (fromIndex === toIndex) return state;
if (fromIndex < 0 || fromIndex >= state.queue.length) return state;
if (toIndex < 0 || toIndex >= state.queue.length) return state;
const newQueue = [...state.queue];
const [removed] = newQueue.splice(fromIndex, 1);
newQueue.splice(toIndex, 0, removed);
// Adjust current index
let newIndex = state.currentIndex;
if (state.currentIndex === fromIndex) {
newIndex = toIndex;
} else if (fromIndex < state.currentIndex && toIndex >= state.currentIndex) {
newIndex = state.currentIndex - 1;
} else if (fromIndex > state.currentIndex && toIndex <= state.currentIndex) {
newIndex = state.currentIndex + 1;
}
return {
...state,
queue: newQueue,
currentIndex: newIndex
};
});
},
// Called when audio element starts playing (confirms playback started)
confirmPlaying() {
update(state => ({
...state,
isPlaying: true,
shouldPlay: false
}));
},
// Clear nowPlaying (when track ends and not repeating)
clearNowPlaying() {
update(state => ({
...state,
nowPlaying: null
}));
}
};
}
export const audioPlaylist = createAudioPlaylistStore();
// Derived store for current track (nowPlaying takes precedence over queue)
export const currentTrack = derived(audioPlaylist, $playlist => {
// If there's a nowPlaying track, that's the current track
if ($playlist.nowPlaying) return $playlist.nowPlaying;
// Otherwise use the queue
if ($playlist.queue.length === 0) return null;
if ($playlist.currentIndex < 0 || $playlist.currentIndex >= $playlist.queue.length) return null;
return $playlist.queue[$playlist.currentIndex];
});
// Derived store for whether there's a next track
export const hasNext = derived(audioPlaylist, $playlist => {
if ($playlist.queue.length === 0) return false;
if ($playlist.repeat === 'all' || $playlist.shuffle) return true;
return $playlist.currentIndex < $playlist.queue.length - 1;
});
// Derived store for whether there's a previous track
export const hasPrevious = derived(audioPlaylist, $playlist => {
if ($playlist.queue.length === 0) return false;
if ($playlist.repeat === 'all' || $playlist.shuffle) return true;
return $playlist.currentIndex > 0 || $playlist.currentTime > 3;
});

View file

@ -39,16 +39,17 @@ function createAuthStore() {
credentials: 'include', // Receive httpOnly cookie
body: JSON.stringify(credentials)
});
const data = await response.json();
if (response.ok && data.success) {
// Server sets httpOnly cookie, we just store user data
// Server sets httpOnly cookie for HTTP requests
// Token is NOT stored in localStorage to prevent XSS attacks
set({ user: data.user, loading: false });
goto('/');
return { success: true };
}
return { success: false, error: data.error || 'Invalid credentials' };
},
@ -59,16 +60,17 @@ function createAuthStore() {
credentials: 'include', // Receive httpOnly cookie
body: JSON.stringify({ username, signature, challenge })
});
const data = await response.json();
if (response.ok && data.success) {
// Server sets httpOnly cookie, we just store user data
// Server sets httpOnly cookie for HTTP requests
// Token is NOT stored in localStorage to prevent XSS attacks
set({ user: data.user, loading: false });
goto('/');
return { success: true };
}
return { success: false, error: data.error || 'Invalid signature' };
},
@ -122,14 +124,25 @@ function createAuthStore() {
user: userData
}));
},
getUser() {
let user = null;
subscribe(state => { user = state.user; })();
return user;
},
async logout() {
// Call logout endpoint to clear httpOnly cookie
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
// Clear token from localStorage
if (browser) {
localStorage.removeItem('token');
}
set({ user: null, loading: false });
goto('/login');
}
@ -148,11 +161,46 @@ export const isAdmin = derived(
$auth => $auth.user?.isAdmin || false
);
export const isModerator = derived(
auth,
$auth => $auth.user?.isModerator || false
);
export const isStreamer = derived(
auth,
$auth => $auth.user?.isStreamer || false
);
export const isRestreamer = derived(
auth,
$auth => $auth.user?.isRestreamer || false
);
export const isBot = derived(
auth,
$auth => $auth.user?.isBot || false
);
export const isStickerCreator = derived(
auth,
$auth => $auth.user?.isStickerCreator || false
);
export const isUploader = derived(
auth,
$auth => $auth.user?.isUploader || false
);
export const isTexter = derived(
auth,
$auth => $auth.user?.isTexter || false
);
export const isWatchCreator = derived(
auth,
$auth => $auth.user?.isWatchCreator || false
);
export const userColor = derived(
auth,
$auth => $auth.user?.colorCode || '#561D5E'

View file

@ -0,0 +1,105 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const STORAGE_KEY = 'chatLayoutPrefs';
const defaultPrefs = {
position: 'right', // 'left' | 'right' - X axis
inverted: false, // true = input on top, false = input on bottom
messagesFromTop: false, // true = messages stack from top, false = from bottom
theaterMode: false // transparent overlay mode
};
function loadPrefs() {
if (!browser) return defaultPrefs;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return { ...defaultPrefs, ...JSON.parse(stored) };
}
} catch (e) {
console.error('Failed to load chat layout prefs:', e);
}
return defaultPrefs;
}
function createChatLayoutStore() {
const { subscribe, set, update } = writable(loadPrefs());
return {
subscribe,
togglePosition() {
update(prefs => {
const newPrefs = {
...prefs,
position: prefs.position === 'right' ? 'left' : 'right'
};
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newPrefs));
}
return newPrefs;
});
},
toggleInverted() {
update(prefs => {
const newPrefs = {
...prefs,
inverted: !prefs.inverted
};
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newPrefs));
}
return newPrefs;
});
},
toggleTheaterMode() {
update(prefs => {
const newPrefs = {
...prefs,
theaterMode: !prefs.theaterMode
};
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newPrefs));
}
return newPrefs;
});
},
toggleMessagesFromTop() {
update(prefs => {
const newPrefs = {
...prefs,
messagesFromTop: !prefs.messagesFromTop
};
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newPrefs));
}
return newPrefs;
});
},
setPosition(position) {
update(prefs => {
const newPrefs = { ...prefs, position };
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newPrefs));
}
return newPrefs;
});
},
reset() {
set(defaultPrefs);
if (browser) {
localStorage.removeItem(STORAGE_KEY);
}
}
};
}
export const chatLayout = createChatLayoutStore();

View file

@ -0,0 +1,205 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
const STORAGE_KEY = 'ebookReader';
const POSITIONS_KEY = 'ebookPositions';
const defaultState = {
enabled: false, // Panel visibility
currentBook: null, // { id, title, filePath, coverPath, chapterCount, username, realmName }
progress: 0, // Reading progress percentage (0-100)
showToc: false, // TOC sidebar visibility
minimized: false // Minimized state
};
// Store reading positions separately (per ebook)
function loadPositions() {
if (!browser) return {};
try {
const stored = localStorage.getItem(POSITIONS_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function persistPosition(ebookId, cfi) {
if (!browser || !ebookId) return;
try {
const positions = loadPositions();
positions[ebookId] = cfi;
localStorage.setItem(POSITIONS_KEY, JSON.stringify(positions));
} catch (e) {
console.error('Failed to save ebook position:', e);
}
}
function getPosition(ebookId) {
if (!browser || !ebookId) return null;
const positions = loadPositions();
return positions[ebookId] || null;
}
function loadState() {
if (!browser) return defaultState;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return {
...defaultState,
...parsed,
// Don't persist currentBook or showToc
currentBook: null,
showToc: false
};
}
} catch (e) {
console.error('Failed to load ebook reader state:', e);
}
return defaultState;
}
function saveState(state) {
if (!browser) return;
try {
const toSave = {
enabled: state.enabled,
minimized: state.minimized
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {
console.error('Failed to save ebook reader state:', e);
}
}
function createEbookReaderStore() {
const { subscribe, set, update } = writable(loadState());
const updateAndSave = (fn) => {
update(state => {
const newState = fn(state);
saveState(newState);
return newState;
});
};
return {
subscribe,
// Open a book in the reader panel
openBook(book) {
if (!book?.id || !book?.filePath) {
console.error('Invalid book object - missing id or filePath');
return;
}
update(state => ({
...state,
enabled: true,
currentBook: book,
progress: 0,
showToc: false,
minimized: false
}));
},
// Close the reader (optionally save position first)
closeBook(cfi = null) {
update(state => {
// Save position if provided
if (cfi && state.currentBook?.id) {
persistPosition(state.currentBook.id, cfi);
}
return {
...state,
enabled: false,
currentBook: null,
progress: 0,
showToc: false
};
});
},
// Toggle panel visibility
toggle() {
updateAndSave(state => ({
...state,
enabled: !state.enabled
}));
},
// Show panel
show() {
updateAndSave(state => ({
...state,
enabled: true
}));
},
// Hide panel (keep book loaded)
hide() {
updateAndSave(state => ({
...state,
enabled: false
}));
},
// Update reading progress
setProgress(percent) {
update(state => ({
...state,
progress: Math.max(0, Math.min(100, percent))
}));
},
// Save reading position (CFI)
savePosition(cfi) {
update(state => {
if (state.currentBook?.id && cfi) {
persistPosition(state.currentBook.id, cfi);
}
return state;
});
},
// Get saved position for an ebook
getPosition(ebookId) {
return getPosition(ebookId);
},
// Toggle TOC sidebar
toggleToc() {
update(state => ({
...state,
showToc: !state.showToc
}));
},
// Toggle minimized state
toggleMinimized() {
updateAndSave(state => ({
...state,
minimized: !state.minimized
}));
},
// Set minimized state
setMinimized(minimized) {
updateAndSave(state => ({
...state,
minimized
}));
}
};
}
export const ebookReader = createEbookReaderStore();
// Derived store for whether a book is currently open
export const hasBook = derived(ebookReader, $reader => $reader.currentBook !== null);
// Derived store for current book info
export const currentBook = derived(ebookReader, $reader => $reader.currentBook);

View file

@ -0,0 +1,158 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
const STORAGE_KEY = 'gamesOverlay';
const defaultState = {
enabled: false, // Overlay visibility
matchId: null, // Current match ID
mode: null, // 'waiting' | 'playing' | 'spectating' | 'finished'
minimized: false, // Minimized to header only
gameState: null // Current game state { fen, turn, players, etc. }
};
function loadState() {
if (!browser) return defaultState;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Only persist minimized state - NEVER persist enabled/matchId
// This ensures overlay is always closed on page load
return {
enabled: false,
matchId: null,
mode: null,
gameState: null,
minimized: parsed.minimized || false
};
}
} catch (e) {
console.error('Failed to load games overlay state:', e);
}
return defaultState;
}
function saveState(state) {
if (!browser) return;
try {
const toSave = {
minimized: state.minimized
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {
console.error('Failed to save games overlay state:', e);
}
}
function createGamesOverlayStore() {
const { subscribe, set, update } = writable(loadState());
const updateAndSave = (fn) => {
update(state => {
const newState = fn(state);
saveState(newState);
return newState;
});
};
return {
subscribe,
// Open a game in the overlay (as player or spectator)
openGame(matchId, mode = 'waiting', gameState = null) {
update(state => ({
...state,
enabled: true,
matchId,
mode,
gameState,
minimized: false
}));
},
// Close the game overlay
closeGame() {
update(state => ({
...state,
enabled: false,
matchId: null,
mode: null,
gameState: null
}));
},
// Update game state from Nakama match data
updateState(gameState) {
update(state => ({
...state,
gameState: {
...state.gameState,
...gameState
}
}));
},
// Update the mode
setMode(mode) {
update(state => ({
...state,
mode
}));
},
// Toggle overlay visibility
toggle() {
update(state => ({
...state,
enabled: !state.enabled
}));
},
// Show overlay
show() {
update(state => ({
...state,
enabled: true
}));
},
// Hide overlay (keep game state)
hide() {
update(state => ({
...state,
enabled: false
}));
},
// Toggle minimized state
toggleMinimized() {
updateAndSave(state => ({
...state,
minimized: !state.minimized
}));
},
// Set minimized state
setMinimized(minimized) {
updateAndSave(state => ({
...state,
minimized
}));
}
};
}
export const gamesOverlay = createGamesOverlayStore();
// Derived store for whether a game is currently open
export const hasGame = derived(gamesOverlay, $overlay => $overlay.matchId !== null);
// Derived store for current game state
export const currentGame = derived(gamesOverlay, $overlay => $overlay.gameState);
// Derived store for current mode
export const gameMode = derived(gamesOverlay, $overlay => $overlay.mode);

View file

@ -0,0 +1,500 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
// Nakama configuration from environment
const NAKAMA_SERVER_KEY = import.meta.env.VITE_NAKAMA_SERVER_KEY || 'defaultkey';
const NAKAMA_HOST = import.meta.env.VITE_NAKAMA_HOST || 'localhost';
const NAKAMA_PORT = import.meta.env.VITE_NAKAMA_PORT || '80';
const NAKAMA_USE_SSL = import.meta.env.VITE_NAKAMA_USE_SSL === 'true';
// Polling interval for games lists (ms)
export const GAMES_POLL_INTERVAL = 30000;
// Op codes for chess game messages
export const ChessOpCode = {
MOVE: 1,
GAME_STATE: 2,
GAME_OVER: 3,
RESIGN: 4,
OFFER_DRAW: 5,
ACCEPT_DRAW: 6
};
// Helper to parse RPC response payload (handles both string and object)
function parseRpcPayload(payload) {
if (typeof payload === 'string') {
return JSON.parse(payload);
}
return payload;
}
function createNakamaStore() {
const { subscribe, set, update } = writable({
client: null,
session: null,
socket: null,
connected: false,
error: null,
currentMatch: null
});
let client = null;
let session = null;
let socket = null;
let matchCallbacks = [];
let presenceCallbacks = [];
// Buffer for match events that arrive before any handler is registered
// This prevents race conditions where GAME_STATE is sent before the overlay loads
let matchEventBuffer = [];
const MAX_BUFFER_SIZE = 10;
return {
subscribe,
async init() {
if (!browser) return;
// If client already exists, don't reinitialize (idempotent)
if (client) {
return { success: true };
}
try {
// Dynamic import of nakama-js
const { Client } = await import('@heroiclabs/nakama-js');
client = new Client(
NAKAMA_SERVER_KEY,
NAKAMA_HOST,
NAKAMA_PORT,
NAKAMA_USE_SSL
);
set({
client,
session: null,
socket: null,
connected: false,
error: null,
currentMatch: null
});
console.log('Nakama client initialized');
return { success: true };
} catch (e) {
console.error('Failed to initialize Nakama client:', e);
update(s => ({ ...s, error: e.message }));
return { success: false, error: e.message };
}
},
async authenticate() {
if (!browser || !client) {
return { success: false, error: 'Client not initialized' };
}
// If already authenticated with a valid session, return it
if (session && !session.isexpired(Date.now() / 1000)) {
return { success: true, session };
}
// Fetch JWT token from API instead of localStorage (prevents XSS token theft)
let appToken;
try {
const tokenResp = await fetch('/api/user/token', { credentials: 'include' });
if (!tokenResp.ok) {
return { success: false, error: 'Not logged in to main app' };
}
const tokenData = await tokenResp.json();
appToken = tokenData.token;
} catch (e) {
return { success: false, error: 'Failed to fetch auth token' };
}
if (!appToken) {
return { success: false, error: 'Not logged in to main app' };
}
try {
// Authenticate with Nakama using custom auth
// Pass app JWT as the custom ID - Nakama validates via hook
session = await client.authenticateCustom(appToken, true);
update(s => ({
...s,
session,
error: null
}));
return { success: true, session };
} catch (e) {
console.error('Nakama authentication failed:', e);
update(s => ({
...s,
session: null,
error: e.message
}));
return { success: false, error: e.message };
}
},
async connectSocket() {
if (!browser || !client || !session) {
return { success: false, error: 'Not authenticated' };
}
// If socket already connected, return success
if (socket) {
console.log('[Nakama] connectSocket: Socket already exists, returning early');
return { success: true };
}
console.log('[Nakama] connectSocket: Creating new socket...');
try {
socket = client.createSocket(NAKAMA_USE_SSL);
// Set up event handlers
socket.onmatchdata = (matchData) => {
console.log('[Nakama] onmatchdata received, op_code:', matchData.op_code, 'callbacks:', matchCallbacks.length);
if (matchCallbacks.length === 0) {
// Buffer the event if no handlers registered yet
// This handles race conditions where GAME_STATE arrives before overlay loads
if (matchEventBuffer.length < MAX_BUFFER_SIZE) {
matchEventBuffer.push({ type: 'matchdata', data: matchData });
console.log('[Nakama] Buffered match event (no handlers yet):', matchData.op_code, 'buffer size:', matchEventBuffer.length);
}
} else {
console.log('[Nakama] Dispatching to', matchCallbacks.length, 'callbacks');
matchCallbacks.forEach(cb => cb('matchdata', matchData));
}
};
socket.onmatchpresence = (matchPresence) => {
presenceCallbacks.forEach(cb => cb(matchPresence));
};
socket.ondisconnect = () => {
update(s => ({
...s,
connected: false,
socket: null
}));
matchCallbacks.forEach(cb => cb('disconnect', null));
};
socket.onerror = (error) => {
console.error('Nakama socket error:', error);
update(s => ({ ...s, error: error.message }));
};
// Connect
await socket.connect(session, true);
update(s => ({
...s,
socket,
connected: true,
error: null
}));
console.log('Nakama socket connected');
return { success: true };
} catch (e) {
console.error('Nakama socket connection failed:', e);
update(s => ({
...s,
socket: null,
connected: false,
error: e.message
}));
return { success: false, error: e.message };
}
},
// Create a new chess match
async createChessMatch() {
if (!client || !session) {
return { success: false, error: 'Not authenticated' };
}
try {
const response = await client.rpc(
session,
'create_chess_match',
'{}'
);
const result = parseRpcPayload(response.payload);
// Check if server returned an error (user already has active challenge)
if (result.error) {
return {
success: false,
error: result.error,
existingMatchId: result.existingMatchId
};
}
return { success: true, matchId: result.matchId };
} catch (e) {
console.error('Failed to create match:', e);
return { success: false, error: e.message };
}
},
// List active chess matches
// status: 'waiting' | 'playing' | 'all'
async listChessMatches(limit = 20, status = 'all') {
if (!client || !session) {
console.warn('listChessMatches: Not authenticated', { client: !!client, session: !!session });
return { success: false, error: 'Not authenticated' };
}
try {
const response = await client.rpc(
session,
'list_chess_matches',
JSON.stringify({ limit, status })
);
const result = parseRpcPayload(response.payload);
console.log('listChessMatches response:', { status, result });
return { success: true, matches: result.matches };
} catch (e) {
console.error('Failed to list matches:', e);
return { success: false, error: e.message };
}
},
// Join a match
async joinMatch(matchId) {
console.log('[Nakama] joinMatch called for:', matchId, 'buffer size:', matchEventBuffer.length);
if (!socket) {
console.error('[Nakama] joinMatch: Socket not connected');
return { success: false, error: 'Socket not connected' };
}
try {
console.log('[Nakama] joinMatch: Calling socket.joinMatch...');
const match = await socket.joinMatch(matchId);
console.log('[Nakama] joinMatch: Success, presences:', match.presences?.length, 'buffer size after:', matchEventBuffer.length);
update(s => ({ ...s, currentMatch: match }));
return { success: true, match };
} catch (e) {
console.error('[Nakama] joinMatch failed:', e);
return { success: false, error: e.message };
}
},
// Leave current match
async leaveMatch(matchId) {
if (!socket) {
return { success: false, error: 'Socket not connected' };
}
try {
await socket.leaveMatch(matchId);
matchEventBuffer = []; // Clear buffered events from this match
update(s => ({ ...s, currentMatch: null }));
return { success: true };
} catch (e) {
console.error('Failed to leave match:', e);
return { success: false, error: e.message };
}
},
// Send match data (game moves)
async sendMatchData(matchId, opCode, data) {
if (!socket) {
return { success: false, error: 'Socket not connected' };
}
try {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
await socket.sendMatchState(matchId, opCode, payload);
return { success: true };
} catch (e) {
console.error('Failed to send match data:', e);
return { success: false, error: e.message };
}
},
// Get chess leaderboard
async getChessLeaderboard(limit = 20) {
if (!client || !session) {
return { success: false, error: 'Not authenticated' };
}
try {
const response = await client.rpc(
session,
'get_chess_leaderboard',
JSON.stringify({ limit })
);
const result = parseRpcPayload(response.payload);
return { success: true, records: result.records };
} catch (e) {
console.error('Failed to get leaderboard:', e);
return { success: false, error: e.message };
}
},
// High-level helper: Create a new chess challenge
// Handles init, auth, socket, create, and join in one call
async createChallenge() {
const initResult = await this.init();
if (!initResult?.success) {
return { success: false, error: 'Failed to initialize' };
}
const authResult = await this.authenticate();
if (!authResult.success) {
return { success: false, error: authResult.error || 'Not authenticated' };
}
const socketResult = await this.connectSocket();
if (!socketResult.success) {
return { success: false, error: socketResult.error || 'Failed to connect' };
}
const createResult = await this.createChessMatch();
if (!createResult.success) {
// Handle existing match case
if (createResult.existingMatchId) {
return {
success: false,
error: createResult.error,
existingMatchId: createResult.existingMatchId
};
}
return { success: false, error: createResult.error || 'Failed to create match' };
}
// Join the match to become the white player
await this.joinMatch(createResult.matchId);
// Delay to let Nakama update the match label
await new Promise(resolve => setTimeout(resolve, 400));
return { success: true, matchId: createResult.matchId };
},
// High-level helper: Cancel an existing challenge via RPC
// This uses an explicit RPC to terminate the match, rather than join+leave
// which allows page refresh without killing the match
async cancelChallenge(matchId) {
const initResult = await this.init();
if (!initResult?.success) {
return { success: false, error: 'Failed to initialize' };
}
const authResult = await this.authenticate();
if (!authResult.success) {
return { success: false, error: authResult.error || 'Not authenticated' };
}
try {
const response = await client.rpc(
session,
'cancel_chess_match',
JSON.stringify({ matchId })
);
const result = parseRpcPayload(response.payload);
if (!result.success) {
return { success: false, error: result.error || 'Failed to cancel' };
}
return { success: true };
} catch (e) {
console.error('Failed to cancel challenge:', e);
return { success: false, error: e.message };
}
},
// Register callback for match events
onMatchEvent(callback) {
console.log('[Nakama] onMatchEvent: registering callback, current callbacks:', matchCallbacks.length, 'buffer size:', matchEventBuffer.length);
matchCallbacks.push(callback);
// Replay any buffered events to this new handler
if (matchEventBuffer.length > 0) {
console.log(`[Nakama] Replaying ${matchEventBuffer.length} buffered match events`);
const bufferedEvents = [...matchEventBuffer];
matchEventBuffer = []; // Clear buffer
// Use setTimeout to ensure the handler is fully set up
setTimeout(() => {
console.log('[Nakama] Executing replay of', bufferedEvents.length, 'events');
bufferedEvents.forEach(event => {
console.log('[Nakama] Replaying event type:', event.type, 'op_code:', event.data?.op_code);
callback(event.type, event.data);
});
}, 0);
}
return () => {
matchCallbacks = matchCallbacks.filter(cb => cb !== callback);
console.log('[Nakama] Callback unregistered, remaining:', matchCallbacks.length);
};
},
// Register callback for presence events
onPresence(callback) {
presenceCallbacks.push(callback);
return () => {
presenceCallbacks = presenceCallbacks.filter(cb => cb !== callback);
};
},
// Disconnect and cleanup
disconnect() {
if (socket) {
socket.disconnect(false);
socket = null;
}
session = null;
matchCallbacks = [];
presenceCallbacks = [];
matchEventBuffer = [];
set({
client,
session: null,
socket: null,
connected: false,
error: null,
currentMatch: null
});
},
// Get current session info
getSession() {
return session;
}
};
}
export const nakama = createNakamaStore();
// Derived stores for convenience
export const isNakamaConnected = derived(
nakama,
$nakama => $nakama.connected
);
export const nakamaSession = derived(
nakama,
$nakama => $nakama.session
);
export const currentMatch = derived(
nakama,
$nakama => $nakama.currentMatch
);
export const nakamaError = derived(
nakama,
$nakama => $nakama.error
);

View file

@ -0,0 +1,7 @@
import { writable } from 'svelte/store';
export const siteSettings = writable({
site_title: 'Stream',
logo_path: '',
logo_display_mode: 'text'
});

View file

@ -0,0 +1,91 @@
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
// Writable store for sticker array
export const stickers = writable([]);
// Derived store for sticker map (name -> filePath lookup)
export const stickersMap = derived(stickers, ($stickers) => {
const map = {};
$stickers.forEach((sticker) => {
map[sticker.name.toLowerCase()] = sticker.filePath;
});
return map;
});
// Loading state
let loading = false;
let loaded = false;
let loadPromise = null;
/**
* Load stickers from API (deduped - only fetches once)
* @returns {Promise<void>}
*/
export async function loadStickers() {
// Already loaded
if (loaded) return;
// Already loading - return existing promise
if (loading && loadPromise) return loadPromise;
// Not in browser
if (!browser) return;
loading = true;
loadPromise = (async () => {
try {
const response = await fetch('/api/admin/stickers');
if (response.ok) {
const data = await response.json();
stickers.set(data.stickers || []);
loaded = true;
}
} catch (e) {
console.error('Failed to load stickers:', e);
} finally {
loading = false;
}
})();
return loadPromise;
}
/**
* Ensure stickers are loaded before proceeding
* Use this in components that need stickers immediately
* @returns {Promise<void>}
*/
export async function ensureLoaded() {
if (loaded) return;
await loadStickers();
}
/**
* Check if stickers are loaded
* @returns {boolean}
*/
export function isLoaded() {
return loaded;
}
/**
* Get sticker file path by name (synchronous)
* @param {string} name - Sticker name (case-insensitive)
* @returns {string|undefined} - File path or undefined if not found
*/
export function getStickerPath(name) {
const map = get(stickersMap);
return map[name.toLowerCase()];
}
/**
* Reset store (for testing/hot reload)
*/
export function resetStickers() {
stickers.set([]);
loaded = false;
loading = false;
loadPromise = null;
}

View file

@ -0,0 +1,149 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const STORAGE_KEY = 'streamTiles';
const defaultState = {
enabled: false,
streams: [], // Array of { streamKey, name, username, realmId, offlineImageUrl?, muted: true, volume: 0.5 }
// Grid sizing: percentage splits for resizable dividers
horizontalSplit: 50, // Percentage for left/right split (2 streams side by side)
verticalSplit: 50 // Percentage for top/bottom split (2x2 grid)
};
function loadFromStorage() {
if (!browser) return defaultState;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Migrate old format: ensure streams have muted/volume properties
if (parsed.streams) {
parsed.streams = parsed.streams.map(s => ({
...s,
muted: s.muted !== undefined ? s.muted : true,
volume: s.volume !== undefined ? s.volume : 0.5
}));
}
// Migrate: remove old unmutedStream if present
delete parsed.unmutedStream;
return { ...defaultState, ...parsed };
}
} catch (e) {
console.error('Failed to load streamTiles from storage:', e);
}
return defaultState;
}
function saveToStorage(state) {
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.error('Failed to save streamTiles to storage:', e);
}
}
function createTileStore() {
const { subscribe, update, set } = writable(loadFromStorage());
return {
subscribe,
toggle: () => update(s => {
const newState = { ...s, enabled: !s.enabled };
saveToStorage(newState);
return newState;
}),
show: () => update(s => {
const newState = { ...s, enabled: true };
saveToStorage(newState);
return newState;
}),
hide: () => update(s => {
const newState = { ...s, enabled: false };
saveToStorage(newState);
return newState;
}),
addStream: (stream) => update(s => {
// Don't add duplicates
if (s.streams.some(st => st.streamKey === stream.streamKey)) {
return s;
}
// Max 4 streams, add with default audio settings
const newStream = {
...stream,
muted: true,
volume: 0.5
};
const newStreams = [...s.streams.slice(-(3)), newStream];
const newState = { ...s, streams: newStreams, enabled: true };
saveToStorage(newState);
return newState;
}),
removeStream: (streamKey) => update(s => {
const newStreams = s.streams.filter(st => st.streamKey !== streamKey);
const newState = { ...s, streams: newStreams };
saveToStorage(newState);
return newState;
}),
toggleMute: (streamKey) => update(s => {
const newStreams = s.streams.map(st =>
st.streamKey === streamKey
? { ...st, muted: !st.muted }
: st
);
const newState = { ...s, streams: newStreams };
saveToStorage(newState);
return newState;
}),
setVolume: (streamKey, volume) => update(s => {
const newStreams = s.streams.map(st =>
st.streamKey === streamKey
? { ...st, volume: Math.max(0, Math.min(1, volume)) }
: st
);
const newState = { ...s, streams: newStreams };
saveToStorage(newState);
return newState;
}),
setMuted: (streamKey, muted) => update(s => {
const newStreams = s.streams.map(st =>
st.streamKey === streamKey
? { ...st, muted }
: st
);
const newState = { ...s, streams: newStreams };
saveToStorage(newState);
return newState;
}),
// Update grid split percentages
setHorizontalSplit: (percent) => update(s => {
const newState = { ...s, horizontalSplit: Math.max(20, Math.min(80, percent)) };
saveToStorage(newState);
return newState;
}),
setVerticalSplit: (percent) => update(s => {
const newState = { ...s, verticalSplit: Math.max(20, Math.min(80, percent)) };
saveToStorage(newState);
return newState;
}),
clear: () => {
const newState = { ...defaultState };
saveToStorage(newState);
set(newState);
}
};
}
export const streamTiles = createTileStore();

View file

@ -0,0 +1,164 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import { auth } from './auth';
// Store for current user's übercoin balance
export const ubercoinBalance = writable(0);
// Store for treasury info
export const treasury = writable({
balance: 0,
totalDestroyed: 0,
totalUsers: 0,
estimatedShare: 0,
nextDistribution: null,
lastGrowthAt: null,
lastDistributionAt: null
});
// Format übercoin amount for display (3 decimal places)
export function formatUbercoin(amount) {
if (amount === null || amount === undefined) return '0.000';
const num = parseFloat(amount);
if (isNaN(num)) return '0.000';
// Format with exactly 3 decimal places
return num.toFixed(3);
}
// Format übercoin with compact notation for large numbers
export function formatUbercoinCompact(amount) {
if (amount === null || amount === undefined) return '0.000';
const num = parseFloat(amount);
if (isNaN(num)) return '0.000';
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(2) + 'K';
}
return num.toFixed(3);
}
// Fetch current user's balance (called when user data is loaded)
export async function fetchBalance() {
if (!browser) return;
try {
const response = await fetch('/api/user/me', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success && data.user) {
ubercoinBalance.set(data.user.ubercoinBalance || 0);
}
}
} catch (error) {
console.error('Failed to fetch übercoin balance:', error);
}
}
// Preview a transaction (get burn rate before sending)
export async function previewTransaction(recipientUsername, amount) {
if (!browser) return { success: false, error: 'Not in browser' };
try {
const response = await fetch('/api/ubercoin/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
recipient: recipientUsername,
amount: parseFloat(amount)
})
});
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to preview transaction:', error);
return { success: false, error: 'Network error' };
}
}
// Send übercoin to another user
export async function sendUbercoin(recipientUsername, amount) {
if (!browser) return { success: false, error: 'Not in browser' };
try {
const response = await fetch('/api/ubercoin/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
recipient: recipientUsername,
amount: parseFloat(amount)
})
});
const data = await response.json();
if (data.success) {
// Update local balance
ubercoinBalance.set(data.newBalance);
}
return data;
} catch (error) {
console.error('Failed to send übercoin:', error);
return { success: false, error: 'Network error' };
}
}
// Fetch treasury info
export async function fetchTreasury() {
if (!browser) return;
try {
const response = await fetch('/api/ubercoin/treasury', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.success) {
treasury.set({
balance: data.balance || 0,
totalDestroyed: data.totalDestroyed || 0,
totalUsers: data.totalUsers || 0,
estimatedShare: data.estimatedShare || 0,
nextDistribution: data.nextDistribution,
lastGrowthAt: data.lastGrowthAt,
lastDistributionAt: data.lastDistributionAt
});
}
}
} catch (error) {
console.error('Failed to fetch treasury:', error);
}
}
// Calculate time until next distribution
export function getTimeUntilDistribution(nextDistribution) {
if (!nextDistribution) return null;
const now = new Date();
const next = new Date(nextDistribution);
const diff = next.getTime() - now.getTime();
if (diff <= 0) return { days: 0, hours: 0, minutes: 0 };
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
return { days, hours, minutes };
}
// Derived store for user's übercoin from auth
export const userUbercoin = derived(
auth,
$auth => $auth.user?.ubercoinBalance || 0
);

View file

@ -1,93 +0,0 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
function createUserStore() {
// Initialize from localStorage if in browser
const initialUser = browser ? JSON.parse(localStorage.getItem('user') || 'null') : null;
const { subscribe, set, update } = writable(initialUser);
return {
subscribe,
set: (user) => {
if (browser && user) {
localStorage.setItem('user', JSON.stringify(user));
} else if (browser) {
localStorage.removeItem('user');
}
set(user);
},
update: (fn) => {
update(currentUser => {
const newUser = fn(currentUser);
if (browser && newUser) {
localStorage.setItem('user', JSON.stringify(newUser));
}
return newUser;
});
},
updateColor: async (newColor) => {
const token = browser ? localStorage.getItem('token') : null;
if (!token) return false;
try {
const response = await fetch('/api/user/color', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ color: newColor })
});
const data = await response.json();
if (data.success) {
// Update the store with new user data
if (data.user) {
// Full user data returned
set(data.user);
} else {
// Only color returned, update existing user
update(u => u ? { ...u, userColor: data.color } : null);
}
return true;
}
return false;
} catch (error) {
console.error('Failed to update color:', error);
return false;
}
},
refresh: async () => {
const token = browser ? localStorage.getItem('token') : null;
if (!token) return;
try {
const response = await fetch('/api/user/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
if (data.success && data.user) {
set(data.user);
return data.user;
}
}
} catch (error) {
console.error('Failed to refresh user:', error);
}
return null;
}
};
}
export const userStore = createUserStore();
// Derived store for just the color
export const userColor = derived(
userStore,
$user => $user?.userColor || '#561D5E'
);

View file

@ -0,0 +1,394 @@
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost/ws';
const SYNC_INTERVAL = 5000; // Sync every 5 seconds (server pushes every 1s anyway)
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync)
const LEAD_IN_DURATION = 3000; // 3 seconds lead-in for buffering
function createWatchSyncStore() {
let ws = null;
let reconnectTimeout = null;
let reconnectAttempts = 0;
let syncInterval = null;
let currentRealmId = null;
const MAX_RECONNECT_ATTEMPTS = 10;
const BASE_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const { subscribe, set, update } = writable({
connected: false,
realmId: null,
realmName: '',
canAddToPlaylist: false,
canControlPlayback: false,
playbackState: 'paused', // 'playing', 'paused', 'ended'
currentTime: 0,
serverTime: 0,
currentVideo: null,
viewerCount: 0,
playlist: [],
error: null,
loading: true,
leadIn: false, // True during initial buffering period
repeatCount: 0, // Current repeat count for last video (0-3)
isRepeating: false // True when last video is repeating
});
function getReconnectDelay() {
const exponentialDelay = BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts);
const cappedDelay = Math.min(exponentialDelay, MAX_RECONNECT_DELAY);
const jitter = cappedDelay * 0.2 * (Math.random() - 0.5);
return Math.floor(cappedDelay + jitter);
}
function getExpectedTime(state) {
if (state.playbackState !== 'playing') {
return state.currentTime;
}
// Calculate expected position based on server time
const elapsed = (Date.now() - state.serverTime) / 1000;
return state.currentTime + elapsed;
}
function handleMessage(data) {
switch (data.type) {
case 'welcome':
update(state => ({
...state,
connected: true,
realmId: data.realmId,
realmName: data.realmName || '',
canAddToPlaylist: data.canAddToPlaylist || false,
canControlPlayback: data.canControlPlayback || false,
playbackState: data.playbackState || 'paused',
currentTime: data.currentTime || 0,
serverTime: data.serverTime || Date.now(),
currentVideo: data.currentVideo || null,
viewerCount: data.viewerCount || 1,
loading: false,
error: null,
leadIn: data.leadIn || false
}));
break;
case 'sync':
update(state => ({
...state,
playbackState: data.playbackState || state.playbackState,
currentTime: data.currentTime ?? state.currentTime,
serverTime: data.serverTime || Date.now(),
currentVideo: data.currentVideo !== undefined ? data.currentVideo : state.currentVideo,
leadIn: data.leadIn ?? state.leadIn
}));
break;
case 'state_change':
// Handle repeat event specifically
if (data.event === 'repeat') {
update(state => ({
...state,
playbackState: 'playing',
currentTime: 0,
serverTime: data.serverTime || Date.now(),
currentVideo: data.currentVideo !== undefined ? data.currentVideo : state.currentVideo,
leadIn: true,
repeatCount: data.repeatCount || 0,
isRepeating: true
}));
} else if (data.event === 'skip') {
// Skip resets repeat state
update(state => ({
...state,
playbackState: data.playbackState || state.playbackState,
currentTime: data.currentTime ?? state.currentTime,
serverTime: data.serverTime || Date.now(),
currentVideo: data.currentVideo !== undefined ? data.currentVideo : state.currentVideo,
leadIn: data.leadIn ?? state.leadIn,
repeatCount: 0,
isRepeating: false
}));
} else {
update(state => ({
...state,
playbackState: data.playbackState || state.playbackState,
currentTime: data.currentTime ?? state.currentTime,
serverTime: data.serverTime || Date.now(),
currentVideo: data.currentVideo !== undefined ? data.currentVideo : state.currentVideo,
leadIn: data.leadIn ?? state.leadIn
}));
}
// Notify listeners about the event
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('watch-state-change', {
detail: { ...data }
}));
}
break;
case 'playlist_update':
update(state => ({
...state,
playlist: data.playlist || state.playlist
}));
break;
case 'viewer_count':
update(state => ({
...state,
viewerCount: data.count || 0
}));
break;
case 'duration_update':
// Server reports updated duration for a playlist item
update(state => {
if (state.currentVideo && state.currentVideo.id === data.playlistItemId) {
return {
...state,
currentVideo: {
...state.currentVideo,
durationSeconds: data.duration
}
};
}
return state;
});
break;
case 'error':
update(state => ({
...state,
error: data.error || 'Unknown error'
}));
break;
}
}
function connect(realmId, token = null) {
if (!browser) return;
if (ws?.readyState === WebSocket.OPEN && currentRealmId === realmId) return;
// Disconnect from previous room if connected
if (ws) {
disconnect();
}
currentRealmId = realmId;
update(state => ({ ...state, loading: true, error: null }));
// Get token from localStorage if not provided
const authToken = token || localStorage.getItem('token');
// Build WebSocket URL
let wsUrl = `${WS_URL.replace('/ws', '')}/watch/ws?realmId=${encodeURIComponent(realmId)}`;
if (authToken) {
wsUrl += `&token=${encodeURIComponent(authToken)}`;
}
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Watch sync WebSocket connected');
reconnectAttempts = 0;
update(state => ({ ...state, connected: true }));
// Start sync interval
if (syncInterval) clearInterval(syncInterval);
syncInterval = setInterval(() => {
requestSync();
}, SYNC_INTERVAL);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (error) {
console.error('Failed to parse watch sync message:', error);
}
};
ws.onerror = (error) => {
console.error('Watch sync WebSocket error:', error);
update(state => ({ ...state, error: 'Connection error' }));
};
ws.onclose = () => {
console.log('Watch sync WebSocket disconnected');
update(state => ({ ...state, connected: false }));
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
// Only reconnect if we haven't explicitly disconnected
if (currentRealmId && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const delay = getReconnectDelay();
reconnectAttempts++;
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
reconnectTimeout = setTimeout(() => {
connect(currentRealmId);
}, delay);
}
};
}
function disconnect() {
currentRealmId = null;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
if (ws) {
ws.close();
ws = null;
}
reconnectAttempts = 0;
set({
connected: false,
realmId: null,
realmName: '',
canAddToPlaylist: false,
canControlPlayback: false,
playbackState: 'paused',
currentTime: 0,
serverTime: 0,
currentVideo: null,
viewerCount: 0,
playlist: [],
error: null,
loading: true,
leadIn: false,
repeatCount: 0,
isRepeating: false
});
}
function send(message) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
function requestSync() {
send({ type: 'sync_request' });
}
function play() {
send({ type: 'play' });
}
function pause() {
send({ type: 'pause' });
}
function seek(time) {
send({ type: 'seek', time });
}
function skip() {
send({ type: 'skip' });
}
// Report video duration to server (called when YouTube player loads a video)
function reportDuration(playlistItemId, duration) {
if (!playlistItemId || !duration || duration <= 0) return;
send({
type: 'update_duration',
playlistItemId: playlistItemId,
duration: Math.floor(duration)
});
}
// Check if local player needs to sync
function checkSync(localTime) {
let state;
subscribe(s => { state = s; })();
const expectedTime = getExpectedTime(state);
const drift = Math.abs(localTime - expectedTime);
if (drift > DRIFT_THRESHOLD) {
return { needsSeek: true, targetTime: expectedTime };
}
return { needsSeek: false, targetTime: expectedTime };
}
return {
subscribe,
connect,
disconnect,
play,
pause,
seek,
skip,
requestSync,
checkSync,
reportDuration,
getExpectedTime: () => {
let state;
subscribe(s => { state = s; })();
return getExpectedTime(state);
}
};
}
export const watchSync = createWatchSyncStore();
// Derived stores for convenience
export const isPlaying = derived(
watchSync,
$watchSync => $watchSync.playbackState === 'playing'
);
export const canControl = derived(
watchSync,
$watchSync => $watchSync.canControlPlayback
);
export const canAddToPlaylist = derived(
watchSync,
$watchSync => $watchSync.canAddToPlaylist
);
export const currentVideo = derived(
watchSync,
$watchSync => $watchSync.currentVideo
);
export const viewerCount = derived(
watchSync,
$watchSync => $watchSync.viewerCount
);
export const watchConnected = derived(
watchSync,
$watchSync => $watchSync.connected
);
export const isLeadIn = derived(
watchSync,
$watchSync => $watchSync.leadIn
);
export const isRepeating = derived(
watchSync,
$watchSync => $watchSync.isRepeating
);
export const repeatCount = derived(
watchSync,
$watchSync => $watchSync.repeatCount
);