import { useIdentity, usePresence, useReactions, useCollection } from '@deplixo/sdk';
/* @component-map
* App — Main container, role switching, tab navigation [app.jsx]
* QueueDisplay — Full queue list with reorder/skip/remove [components/QueueDisplay.jsx]
* CurrentSinger — Current singer spotlight with timer [components/CurrentSinger.jsx]
* SignUpForm — Singer sign-up form [components/SignUpForm.jsx]
* HostControls — Host controls for managing the queue [components/HostControls.jsx]
* AudiencePresenceIndicator — Reusable audience presence and returning-singer badge for the header and tabs.
* @end-component-map */
import { useEffect, useMemo, useRef, useState } from 'react';
import { CurrentSinger } from './components/CurrentSinger.jsx';
import { SignUpForm } from './components/SignUpForm.jsx';
import { QueueDisplay } from './components/QueueDisplay.jsx';
import { HostControls } from './components/HostControls.jsx';
import { playSound } from '@deplixo/sdk';
function normalizeYouTubeTrackInput(value = '') {
const raw = String(value || '').trim();
if (!raw) return '';
const idMatch = raw.match(/^[a-zA-Z0-9_-]{11}$/);
if (idMatch) return raw;
try {
const url = new URL(raw);
if (url.hostname.includes('youtu.be')) {
const id = url.pathname.replace('/', '').trim();
return /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : raw;
}
if (url.hostname.includes('youtube.com')) {
const v = url.searchParams.get('v');
if (v && /^[a-zA-Z0-9_-]{11}$/.test(v)) return v;
const embedMatch = url.pathname.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (embedMatch) return embedMatch[1];
}
} catch {
return raw;
}
return raw;
}
function getYouTubeEmbedUrl(value = '') {
const normalized = normalizeYouTubeTrackInput(value);
if (!normalized) return '';
if (/^https?:\/\//i.test(normalized) && !/youtu(be|\.be)/i.test(normalized)) return '';
if (/^[a-zA-Z0-9_-]{11}$/.test(normalized)) {
return `https://www.youtube.com/embed/${normalized}`;
}
if (/youtu(be|\.be)/i.test(normalized)) {
const id = normalizeYouTubeTrackInput(normalized);
return /^[a-zA-Z0-9_-]{11}$/.test(id) ? `https://www.youtube.com/embed/${id}` : '';
}
return '';
}
function getYouTubeDisplayValue(value = '') {
const normalized = normalizeYouTubeTrackInput(value);
return normalized;
}
function YouTubeTrackEmbed({ trackValue, label = 'Backing track' }) {
const embedUrl = useMemo(() => getYouTubeEmbedUrl(trackValue), [trackValue]);
const displayValue = useMemo(() => getYouTubeDisplayValue(trackValue), [trackValue]);
if (!displayValue) return null;
return (
{label}
{displayValue}
{embedUrl ? (
) : (
Paste a YouTube URL or 11-character video ID to embed the player.
)}
);
}
function ReactionSummary({ targetId, singerName, onApplause }) {
const { counts, userReactions, loading } = useReactions(targetId);
const emojis = ['👍', '❤️', '🎉', '🔥', '👀'];
const applauseEmoji = '🎉';
const lastApplauseCountRef = useRef(0);
const applauseTriggeredRef = useRef(false);
const totalVotes = useMemo(
() => emojis.reduce((sum, emoji) => sum + (counts?.[emoji] || 0), 0),
[counts]
);
const topReaction = useMemo(() => {
return emojis
.map(emoji => ({ emoji, count: counts?.[emoji] || 0 }))
.sort((a, b) => b.count - a.count)[0];
}, [counts]);
useEffect(() => {
const applauseCount = counts?.[applauseEmoji] || 0;
const previousCount = lastApplauseCountRef.current;
if (applauseCount > previousCount) {
if (typeof onApplause === 'function') onApplause(applauseCount - previousCount, applauseCount);
if (applauseTriggeredRef.current) {
playSound('@ding');
}
}
lastApplauseCountRef.current = applauseCount;
applauseTriggeredRef.current = true;
}, [counts, onApplause]);
useEffect(() => {
if (!counts) return;
const applauseCount = counts?.[applauseEmoji] || 0;
if (applauseCount >= 5 && applauseTriggeredRef.current) {
playSound('@success');
}
}, [counts]);
if (loading) return null;
return (
Live audience votes
{totalVotes} reactions
{emojis.map(emoji => (
{emoji}
{counts?.[emoji] || 0}
))}
{topReaction?.count ? `Top vote: ${topReaction.emoji} ${topReaction.count}` : 'Be the first to react!'}
{singerName ? For {singerName} : null}
);
}
function App() {
const [activeTab, setActiveTab] = useState('now-playing');
const { user: identityUser, loading: identityLoading } = useIdentity();
const [audioUnlocked, setAudioUnlocked] = useState(false);
const [roomLink, setRoomLink] = useState('');
const [shareStatus, setShareStatus] = useState('');
const lastSingerKeyRef = useRef('');
const applauseHandledRef = useRef(false);
const { users: audienceUsers, update: updatePresence } = usePresence(
identityUser
? {
name: identityUser.name,
avatar: identityUser.avatar,
status: 'audience',
}
: {
name: 'Guest',
avatar: '🎤',
status: 'audience',
}
);
const { items: singerHistoryItems, add: addSingerHistory, update: updateSingerHistory } = useCollection('singer-history', { personal: true });
const { items: backingTrackItems, add: addBackingTrack, update: updateBackingTrack } = useCollection('backing-tracks', { personal: true });
const { items: performanceVoteItems, add: addPerformanceVote, update: updatePerformanceVote } = useCollection('performance-votes', { personal: true });
const tabs = [
{ id: 'now-playing', label: '🎤 Now Playing', icon: '🎤' },
{ id: 'sign-up', label: '✍️ Sign Up', icon: '✍️' },
{ id: 'queue', label: '📋 Queue', icon: '📋' },
{ id: 'leaderboard', label: '🏆 Leaderboard', icon: '🏆' },
{ id: 'host', label: '⚙️ Host', icon: '⚙️' },
];
useEffect(() => {
if (typeof window === 'undefined') return;
setRoomLink(window.location.href);
}, []);
useEffect(() => {
if (!identityLoading && identityUser && typeof updatePresence === 'function') {
updatePresence({
name: identityUser.name,
avatar: identityUser.avatar,
status: 'audience',
});
}
}, [identityLoading, identityUser, updatePresence]);
useEffect(() => {
const unlockAudio = () => setAudioUnlocked(true);
window.addEventListener('pointerdown', unlockAudio, { once: true });
window.addEventListener('keydown', unlockAudio, { once: true });
return () => {
window.removeEventListener('pointerdown', unlockAudio);
window.removeEventListener('keydown', unlockAudio);
};
}, []);
const audienceCount = Array.isArray(audienceUsers) ? audienceUsers.length : 0;
const currentPerformanceId = useMemo(
() => (identityUser?.name ? `current-performance:${identityUser.name}` : 'current-performance:guest'),
[identityUser?.name]
);
const currentSingerKey = currentPerformanceId;
const currentSingerHistory = useMemo(() => {
const singerName = identityUser?.name || '';
if (!singerName || !Array.isArray(singerHistoryItems)) return { count: 0, item: null };
const normalized = singerName.trim().toLowerCase();
const item = singerHistoryItems.find(entry => (entry?.singerName || '').trim().toLowerCase() === normalized) || null;
return {
count: item?.performerCount || 0,
item,
};
}, [singerHistoryItems, identityUser?.name]);
const currentBackingTrack = useMemo(() => {
if (!Array.isArray(backingTrackItems)) return null;
return backingTrackItems.find(item => item?.status === 'current') || backingTrackItems[0] || null;
}, [backingTrackItems]);
const queuedBackingTracks = useMemo(() => {
if (!Array.isArray(backingTrackItems)) return [];
return backingTrackItems.filter(item => item?.id !== currentBackingTrack?.id);
}, [backingTrackItems, currentBackingTrack?.id]);
const topPerformers = useMemo(() => {
const map = new Map();
const votes = Array.isArray(performanceVoteItems) ? performanceVoteItems : [];
votes.forEach(vote => {
const performanceId = String(vote?.performanceId || vote?.targetId || vote?.id || '').trim();
if (!performanceId) return;
const singerName = String(vote?.singerName || vote?.name || vote?.performanceName || 'Anonymous performance').trim() || 'Anonymous performance';
const voteCount = Number(vote?.voteCount ?? vote?.count ?? 0) || 0;
const reactionScore = Number(vote?.reactionScore ?? vote?.score ?? voteCount) || voteCount;
const storedTotal = Number(vote?.totalVotes ?? vote?.votes ?? 0) || 0;
const totalVotes = Math.max(voteCount, reactionScore, storedTotal, 0);
const lastVotedAt = vote?.lastVotedAt || vote?.updatedAt || vote?.createdAt || '';
const entry = map.get(performanceId) || {
performanceId,
singerName,
totalVotes: 0,
lastVotedAt,
};
entry.singerName = singerName || entry.singerName;
entry.totalVotes = Math.max(entry.totalVotes, totalVotes);
if (lastVotedAt && (!entry.lastVotedAt || new Date(lastVotedAt).getTime() > new Date(entry.lastVotedAt).getTime())) {
entry.lastVotedAt = lastVotedAt;
}
map.set(performanceId, entry);
});
return Array.from(map.values()).sort((a, b) => {
if (b.totalVotes !== a.totalVotes) return b.totalVotes - a.totalVotes;
return new Date(b.lastVotedAt || 0).getTime() - new Date(a.lastVotedAt || 0).getTime();
});
}, [performanceVoteItems]);
useEffect(() => {
if (!audioUnlocked) return;
if (lastSingerKeyRef.current && lastSingerKeyRef.current !== currentSingerKey) {
playSound('@whoosh');
}
lastSingerKeyRef.current = currentSingerKey;
}, [audioUnlocked, currentSingerKey]);
const handleApplause = useMemo(() => {
return () => {
if (!audioUnlocked) return;
if (!applauseHandledRef.current) {
playSound('@ding');
applauseHandledRef.current = true;
}
};
}, [audioUnlocked]);
const recordPerformance = useMemo(() => {
return async singerName => {
const cleanName = (singerName || '').trim();
if (!cleanName) return;
const normalized = cleanName.toLowerCase();
const existing = Array.isArray(singerHistoryItems)
? singerHistoryItems.find(entry => (entry?.singerName || '').trim().toLowerCase() === normalized)
: null;
if (existing?.id) {
const nextCount = (existing.performerCount || 0) + 1;
await updateSingerHistory(existing.id, {
singerName: cleanName,
performerCount: nextCount,
lastPerformedAt: new Date().toISOString(),
});
return;
}
await addSingerHistory({
singerName: cleanName,
performerCount: 1,
lastPerformedAt: new Date().toISOString(),
});
};
}, [addSingerHistory, singerHistoryItems, updateSingerHistory]);
useEffect(() => {
const singerName = identityUser?.name || '';
const triggerKey = `${currentSingerKey}:${singerName}`;
if (!singerName) return;
if (lastSingerKeyRef.current === triggerKey) return;
lastSingerKeyRef.current = triggerKey;
recordPerformance(singerName);
}, [currentSingerKey, identityUser?.name, recordPerformance]);
const upsertCurrentBackingTrack = async value => {
const trackValue = normalizeYouTubeTrackInput(value);
if (!trackValue) return;
const existingCurrent = Array.isArray(backingTrackItems) ? backingTrackItems.find(item => item?.status === 'current') : null;
if (existingCurrent?.id) {
await updateBackingTrack(existingCurrent.id, { title: trackValue, url: trackValue, status: 'current', source: 'youtube' });
return;
}
await addBackingTrack({ title: trackValue, url: trackValue, status: 'current', source: 'youtube' });
};
const getRoomShareUrl = () => {
if (typeof window === 'undefined') return '';
return window.location.href;
};
const copyRoomLink = async () => {
const url = getRoomShareUrl();
if (!url) return;
try {
await navigator.clipboard.writeText(url);
setShareStatus('Room link copied');
} catch {
setShareStatus('Copy failed');
}
window.setTimeout(() => setShareStatus(''), 2200);
};
const shareRoomLink = async () => {
const url = getRoomShareUrl();
if (!url) return;
try {
if (navigator.share) {
await navigator.share({
title: 'Karaoke Night',
text: 'Join my Karaoke Night room',
url,
});
setShareStatus('Share sheet opened');
} else {
await copyRoomLink();
return;
}
} catch {
setShareStatus('Share cancelled');
}
window.setTimeout(() => setShareStatus(''), 2200);
};
const recordPerformanceVote = async performance => {
if (!performance?.performanceId) return;
const existing = Array.isArray(performanceVoteItems)
? performanceVoteItems.find(item => String(item?.performanceId || item?.targetId || '').trim() === performance.performanceId)
: null;
const nextTotal = (existing?.totalVotes || existing?.voteCount || existing?.count || performance.totalVotes || 0) + 1;
const payload = {
performanceId: performance.performanceId,
singerName: performance.singerName,
totalVotes: nextTotal,
voteCount: nextTotal,
count: nextTotal,
lastVotedAt: new Date().toISOString(),
};
if (existing?.id) {
await updatePerformanceVote(existing.id, payload);
return;
}
await addPerformanceVote(payload);
};
return (
{activeTab === 'now-playing' && (
)}
{activeTab === 'sign-up' && }
{activeTab === 'queue' && (
{queuedBackingTracks.length > 0 ? (
Queued backing tracks
{queuedBackingTracks.map(track => (
{track.title || 'Untitled track'}
{getYouTubeDisplayValue(track.url || track.youtubeUrl || track.youtubeId || '')}
))}
) : null}
)}
{activeTab === 'leaderboard' && (
🏆 Top-voted performances
Votes are collected per performance and sorted by total audience support.
{topPerformers.length > 0 ? (
{topPerformers.map((performance, index) => (
#{index + 1}
{performance.singerName}
Performance ID: {performance.performanceId}
{performance.totalVotes} votes
))}
) : (
🏆
No votes yet
React during a performance to populate the leaderboard.
)}
Boost a performance
{topPerformers[0] ? (
) : (
Leaderboard voting appears here once performances receive votes.
)}
)}
{activeTab === 'host' && (
)}
);
}
function AudiencePresenceIndicator({ audienceCount = 0, returningSingerName = '', performerCount = 0 }) {
return (
Audience live
👥 {audienceCount} watching
{returningSingerName ? (
Welcome back, {returningSingerName}
) : null}
{returningSingerName ? (
⭐ Performed {performerCount} {performerCount === 1 ? 'time' : 'times'}
) : null}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();