Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
515
frontend/src/lib/stores/audioPlaylist.js
Normal file
515
frontend/src/lib/stores/audioPlaylist.js
Normal 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;
|
||||
});
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
105
frontend/src/lib/stores/chatLayout.js
Normal file
105
frontend/src/lib/stores/chatLayout.js
Normal 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();
|
||||
205
frontend/src/lib/stores/ebookReader.js
Normal file
205
frontend/src/lib/stores/ebookReader.js
Normal 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);
|
||||
158
frontend/src/lib/stores/gamesOverlay.js
Normal file
158
frontend/src/lib/stores/gamesOverlay.js
Normal 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);
|
||||
500
frontend/src/lib/stores/nakama.js
Normal file
500
frontend/src/lib/stores/nakama.js
Normal 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
|
||||
);
|
||||
7
frontend/src/lib/stores/siteSettings.js
Normal file
7
frontend/src/lib/stores/siteSettings.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const siteSettings = writable({
|
||||
site_title: 'Stream',
|
||||
logo_path: '',
|
||||
logo_display_mode: 'text'
|
||||
});
|
||||
91
frontend/src/lib/stores/stickers.js
Normal file
91
frontend/src/lib/stores/stickers.js
Normal 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;
|
||||
}
|
||||
149
frontend/src/lib/stores/streamTiles.js
Normal file
149
frontend/src/lib/stores/streamTiles.js
Normal 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();
|
||||
164
frontend/src/lib/stores/ubercoin.js
Normal file
164
frontend/src/lib/stores/ubercoin.js
Normal 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
|
||||
);
|
||||
|
|
@ -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'
|
||||
);
|
||||
394
frontend/src/lib/stores/watchSync.js
Normal file
394
frontend/src/lib/stores/watchSync.js
Normal 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
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue