import { useState, useEffect, useRef, useMemo, memo } from "react"; import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { useUI, useConfig, useAudio, useGame, } from "../../context/LauncherContext"; import ChooseInstanceModal from "../modals/ChooseInstanceModal"; import { usePlatform } from "../../hooks/usePlatform"; import { lceOnlineService, SocialEntry } from "../../services/LceOnlineService"; import { TauriService } from "../../services/TauriService"; import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; import { listen } from "@tauri-apps/api/event"; interface LceOnlineViewProps { addFriendTarget?: string | null; onClearAddFriendTarget?: () => void; invites?: Array<{ inviteid: string; from: { uuid: string; username: string }; sessionid: string; }>; } const LceOnlineView = memo(function LceOnlineView({ addFriendTarget, onClearAddFriendTarget, invites: invitesProp, }: LceOnlineViewProps) { const { t } = useTranslation(); const { setActiveView, setIsUiHidden } = useUI(); const { animationsEnabled } = useConfig(); const { playPressSound, playBackSound } = useAudio(); const { isAndroid } = usePlatform(); const game = useGame(); const [isSignedIn, setIsSignedIn] = useState(lceOnlineService.signedIn); const opened = useRef(false); const [currentTab, setCurrentTab] = useState< "friends" | "requests" | "invites" >("friends"); const [focusIndex, setFocusIndex] = useState(0); const [friends, setFriends] = useState([]); const [incomingReqs, setIncomingReqs] = useState([]); const [outgoingReqs, setOutgoingReqs] = useState([]); const invites = invitesProp ?? []; const [isHosting, setIsHosting] = useState(lceOnlineService.isHosting); const [isAddingFriend, setIsAddingFriend] = useState(false); const [addFriendUsername, setAddFriendUsername] = useState(""); const addFriendInputRef = useRef(null); const [errorModal, setErrorModal] = useState(null); const [joinTarget, setJoinTarget] = useState<{ inviteid: string; sessionId: string; hostName: string; } | null>(null); const containerRef = useRef(null); const scrollRef = useRef(null); const fetchSocialData = async () => { if (!lceOnlineService.signedIn) return; try { const lists = await lceOnlineService.getSocialLists(); setFriends(lists.friends); setIncomingReqs(lists.requests); setOutgoingReqs([]); } catch (e: unknown) { console.error(e); } }; useEffect(() => { if (isSignedIn) { fetchSocialData(); } }, [isSignedIn]); useEffect(() => { return lceOnlineService.onSessionChange(() => { setIsSignedIn(lceOnlineService.signedIn); setIsHosting(lceOnlineService.isHosting); }); }, []); useEffect(() => { if (isSignedIn) return; if (!opened.current) { opened.current = true; if (isAndroid) { TauriService.startLceOnlineAuth() .then((token) => { lceOnlineService .loginWithTokenAndFetchAccount(token) .catch((e) => console.error(e)); setIsSignedIn(true); }) .catch((e) => console.error("LCE Online auth failed", e)); } else { new WebviewWindow('LCEOnline', { url: "https://mclegacyedition.xyz/internal/auth?appId=emerald_launcher", width: 400, height: 570, resizable: false, title: 'Emerald Legacy Launcher - LCEOnline', }); } }; const unlisten = listen('deep-link', async (event) => { const authUrl = event.payload.find(u => u.startsWith('emerald://')); if (!authUrl) return; const token = new URL(authUrl).searchParams.get('token'); if (token) { lceOnlineService .loginWithTokenAndFetchAccount(token) .catch((e) => console.error(e)); setIsSignedIn(true); } (await WebviewWindow.getByLabel('LCEOnline'))?.close(); }); return () => { unlisten.then(f => f()); }; }, [isSignedIn, isAndroid]); useEffect(() => { if (!addFriendTarget) return; setCurrentTab("friends"); handleAction(() => lceOnlineService.sendFriendRequest(addFriendTarget)); onClearAddFriendTarget?.(); }, [addFriendTarget, onClearAddFriendTarget]); const handleLogout = () => { playPressSound(); lceOnlineService.logoutLocal(); setIsSignedIn(false); }; const handleStartHosting = async () => { playPressSound(); try { const token = lceOnlineService.accessToken ?? ""; if (!token) return; TauriService.startHostRelay(token, 25565).catch(() => {}); lceOnlineService.isHosting = true; } catch (e: unknown) { setErrorModal(e instanceof Error ? e.message : t("lceOnline.failedToStartHosting")); } }; const handleStopHosting = async () => { playPressSound(); try { await TauriService.stopAllProxies(); } catch (e: unknown) { console.warn("Stop hosting failed", e); } lceOnlineService.isHosting = false; }; const handleAction = async (action: () => Promise) => { playPressSound(); try { await action(); fetchSocialData(); } catch (e: unknown) { setErrorModal(e instanceof Error ? e.message : t("lceOnline.anErrorOccurred")); } }; type MenuItem = { id: string; type: "button" | "friend" | "request_in" | "request_out" | "invite"; label: string; onClick: () => void; onClickSecondary?: () => void; }; const menuItems = useMemo(() => { const items: MenuItem[] = []; if (currentTab === "friends") { if (!isHosting) { items.push({ id: "host_game", type: "button", label: t("lceOnline.hostGame"), onClick: handleStartHosting, }); } else { items.push({ id: "stop_hosting", type: "button", label: t("lceOnline.stopHosting"), onClick: handleStopHosting, }); } items.push({ id: "add_friend", type: "button", label: t("lceOnline.addFriend"), onClick: () => { playPressSound(); setIsAddingFriend(true); setAddFriendUsername(""); }, }); items.push({ id: "sign_out", type: "button", label: t("lceOnline.signOut"), onClick: handleLogout, }); friends.forEach((f) => { items.push({ id: `friend_${f.username}`, type: "friend", label: f.displayName || f.username, onClick: () => handleAction(() => lceOnlineService.removeFriend(f.username)), onClickSecondary: isHosting ? () => handleAction(() => lceOnlineService.sendInvite(f.username)) : undefined, }); }); } else if (currentTab === "requests") { incomingReqs.forEach((r) => { items.push({ id: `req_in_${r.username}`, type: "request_in", label: r.displayName || r.username, onClick: () => handleAction(() => lceOnlineService.acceptFriendRequest(r.username)), onClickSecondary: () => handleAction(() => lceOnlineService.declineFriendRequest(r.username)), }); }); outgoingReqs.forEach((r) => { items.push({ id: `req_out_${r.username}`, type: "request_out", label: r.displayName || r.username, onClick: () => handleAction(() => lceOnlineService.declineFriendRequest(r.username)), }); }); } else if (currentTab === "invites") { invites.forEach((inv) => { items.push({ id: `invite_${inv.inviteid}`, type: "invite", label: inv.from.username, onClick: () => handleAction(async () => { const sessionId = await lceOnlineService.acceptInvite( inv.from.username, ); setJoinTarget({ inviteid: inv.inviteid, sessionId, hostName: inv.from.username, }); }), onClickSecondary: () => handleAction(() => lceOnlineService.declineInvite(inv.from.username), ), }); }); } return items; }, [ currentTab, friends, incomingReqs, outgoingReqs, invites, playPressSound, isHosting, t, ]); const tabs: ("friends" | "requests" | "invites")[] = [ "friends", "requests", "invites", ]; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (errorModal) { if (e.key === "Escape" || e.key === "Enter") { setErrorModal(null); } return; } if (isAddingFriend) { if (e.key === "Escape") { setIsAddingFriend(false); playBackSound(); } else if (e.key === "Enter") { if (addFriendUsername.trim() !== "") { handleAction(() => lceOnlineService.sendFriendRequest(addFriendUsername.trim()), ); setIsAddingFriend(false); } } return; } if (!isSignedIn) { if (e.key === "Escape" || e.key === "Backspace") { playBackSound(); setActiveView("main"); return; } return; } if (e.key === "Escape" || e.key === "Backspace") { playBackSound(); setActiveView("main"); return; } const curIdx = tabs.indexOf(currentTab); if (e.key === "q" || e.key === "Q" || e.key === "ArrowLeft") { const next = curIdx > 0 ? tabs[curIdx - 1] : tabs[tabs.length - 1]; setCurrentTab(next); setFocusIndex(0); playPressSound(); return; } if (e.key === "e" || e.key === "E" || e.key === "ArrowRight") { const next = curIdx < tabs.length - 1 ? tabs[curIdx + 1] : tabs[0]; setCurrentTab(next); setFocusIndex(0); playPressSound(); return; } const itemCount = menuItems.length; if (itemCount > 0) { if (e.key === "ArrowDown") { setFocusIndex((prev) => prev === null || prev >= itemCount - 1 ? 0 : prev + 1, ); } else if (e.key === "ArrowUp") { setFocusIndex((prev) => prev === null || prev <= 0 ? itemCount - 1 : prev - 1, ); } else if (e.key === "Enter" && focusIndex !== null) { e.preventDefault(); menuItems[focusIndex]?.onClick(); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ focusIndex, menuItems, currentTab, playBackSound, setActiveView, isAddingFriend, addFriendUsername, errorModal, isSignedIn, ]); useEffect(() => { if (isAddingFriend && addFriendInputRef.current) { addFriendInputRef.current.focus(); } else if (focusIndex !== null) { const el = containerRef.current?.querySelector( `[data-index="${focusIndex}"]`, ) as HTMLElement; if (el) { el.focus(); if (scrollRef.current) { const rect = el.getBoundingClientRect(); const scrollRect = scrollRef.current.getBoundingClientRect(); if (rect.bottom > scrollRect.bottom || rect.top < scrollRect.top) { el.scrollIntoView({ behavior: "smooth", block: "center" }); } } } } }, [focusIndex, isAddingFriend]); const touhouOverlayRef = useRef(null); const touhouBlobUrlRef = useRef(null); const touhouAudioRef = useRef(null); useEffect(() => { const GIF_URL = "https://raw.githubusercontent.com/neoapps-dev/neoapps-dev/main/badapple_small.gif"; const MP3_URL = "https://raw.githubusercontent.com/Soldr/bad-apple-but-its-node.js/master/bad-apple.mp3"; const stopAudio = () => { if (touhouAudioRef.current) { touhouAudioRef.current.pause(); touhouAudioRef.current.src = ""; touhouAudioRef.current = null; } }; const onlineUser = lceOnlineService.account?.username; if (onlineUser === "TOUHOU") { if (!touhouOverlayRef.current) { setIsUiHidden(true); const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:99999;background:#000;display:flex;align-items:center;justify-content:center;cursor:pointer"; const spinner = document.createElement("div"); spinner.textContent = "Loading..."; spinner.style.cssText = "color:#fff;font-family:'Mojangles',monospace;font-size:24px;letter-spacing:4px"; overlay.appendChild(spinner); document.body.appendChild(overlay); const img = document.createElement("img"); img.style.cssText = "width:100%;height:100%;object-fit:contain"; const audio = new Audio(MP3_URL); audio.loop = true; audio.volume = 0.5; touhouAudioRef.current = audio; const audioReady = new Promise((resolve) => { if (audio.readyState >= 3) resolve(); else { audio.oncanplaythrough = () => resolve(); audio.onerror = () => resolve(); } }); const gifReady = fetch(GIF_URL) .then((r) => r.arrayBuffer()) .then((buf) => { const blob = new Blob([buf], { type: "image/gif" }); const url = URL.createObjectURL(blob); touhouBlobUrlRef.current = url; img.src = url; return new Promise((resolve) => { if (img.complete) resolve(); else { img.onload = () => resolve(); img.onerror = () => resolve(); } }); }) .catch(() => { img.src = GIF_URL; return new Promise((resolve) => { if (img.complete) resolve(); else { img.onload = () => resolve(); img.onerror = () => resolve(); } }); }); Promise.all([gifReady, audioReady]).then(() => { spinner.remove(); overlay.appendChild(img); audio.currentTime = 2; audio.play().catch(() => {}); }); const cleanup = () => { stopAudio(); setIsUiHidden(false); if (overlay.parentNode) overlay.remove(); touhouOverlayRef.current = null; if (touhouBlobUrlRef.current) { URL.revokeObjectURL(touhouBlobUrlRef.current); touhouBlobUrlRef.current = null; } }; overlay.onclick = cleanup; touhouOverlayRef.current = overlay; } } else { if (touhouOverlayRef.current) { stopAudio(); touhouOverlayRef.current.remove(); touhouOverlayRef.current = null; if (touhouBlobUrlRef.current) { URL.revokeObjectURL(touhouBlobUrlRef.current); touhouBlobUrlRef.current = null; } setIsUiHidden(false); } } return () => { stopAudio(); setIsUiHidden(false); if (touhouOverlayRef.current) { touhouOverlayRef.current.remove(); touhouOverlayRef.current = null; if (touhouBlobUrlRef.current) { URL.revokeObjectURL(touhouBlobUrlRef.current); touhouBlobUrlRef.current = null; } } }; }, [isSignedIn, setIsUiHidden]); const renderContent = () => { if (!isSignedIn) { return (

LCE Online

{t("lceOnline.awaitingAuthentication")}

); } const topButtons = menuItems.filter((m) => m.type === "button"); const listItems = menuItems.filter((m) => m.type !== "button"); return (
{topButtons.length > 0 && (
{topButtons.map((btn) => { const idx = menuItems.indexOf(btn); const isFocused = focusIndex === idx; return ( ); })}
)}
{currentTab === "friends" ? t("lceOnline.friends") : currentTab === "invites" ? t("lceOnline.invites") : t("lceOnline.pendingRequests")} {listItems.length}
{listItems.length === 0 ? (
{t("lceOnline.noneAvailable")}
) : (
{listItems.map((item) => { const idx = menuItems.indexOf(item); const isFocused = focusIndex === idx; return (
setFocusIndex(idx)} className={`w-full flex items-center justify-between px-4 py-3 relative outline-none border-none rounded ${isFocused ? "bg-black/15 shadow-inner" : "bg-transparent"}`} tabIndex={-1} >
{item.label} @ {item.type === "friend" ? friends.find((f) => `friend_${f.username}` === item.id)?.username : item.type === "request_in" ? incomingReqs.find((r) => `req_in_${r.username}` === item.id)?.username : item.type === "request_out" ? outgoingReqs.find((r) => `req_out_${r.username}` === item.id)?.username : t("lceOnline.invite")}
{item.type === "friend" && ( <> {item.onClickSecondary && ( )} )} {item.type === "request_out" && ( )} {item.type === "invite" && ( <> )} {item.type === "request_in" && ( <> )}
); })}
)}
); }; return (
{isSignedIn && (
{tabs.map((tab) => ( ))}
)}
{renderContent()}
LCEOnline TauriService.openUrl("https://mclegacyedition.xyz/")} />
{isAddingFriend && (

{t("lceOnline.addFriend")}

setAddFriendUsername(e.target.value)} />
)}
{errorModal && (

{t("lceOnline.error")}

{errorModal}

)}
{joinTarget && ( setJoinTarget(null)} playPressSound={playPressSound} playBackSound={playBackSound} editions={game.editions} installs={game.installs} invite={{ inviteId: joinTarget.inviteid, from: joinTarget.hostName, hostIp: "", hostPort: 0, hostName: joinTarget.hostName, sessionId: joinTarget.sessionId, status: "pending", }} /> )}
); }); export default LceOnlineView;