mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-19 00:37:07 +00:00
feat: LCEOnline instead of LCELive
This commit is contained in:
parent
d8cbace39c
commit
b2e3b2a322
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
|
|
@ -507,7 +507,7 @@ const SkinViewer = memo(function SkinViewer({
|
|||
setActiveView("screenshots");
|
||||
} else if (focusIndex === 3) {
|
||||
playPressSound();
|
||||
setActiveView("lcelive");
|
||||
setActiveView("lceonline");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -749,7 +749,7 @@ const SkinViewer = memo(function SkinViewer({
|
|||
onMouseEnter={() => isFocusedSection && setFocusIndex(3)}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setActiveView("lcelive");
|
||||
setActiveView("lceonline");
|
||||
}}
|
||||
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none ${isFocusedSection && focusIndex === 3 ? "" : ""}`}
|
||||
style={
|
||||
|
|
@ -760,11 +760,11 @@ const SkinViewer = memo(function SkinViewer({
|
|||
}
|
||||
: {}
|
||||
}
|
||||
title="LCELive"
|
||||
title="LCE Online"
|
||||
>
|
||||
<img
|
||||
src="/images/friends.png"
|
||||
alt="LCELive"
|
||||
alt="LCE Online"
|
||||
className="w-8 h-8 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import { lceLiveService, GameInvite } from "../../services/LceLiveService";
|
||||
import { lceOnlineService } from "../../services/LceOnlineService";
|
||||
import type { Edition } from "../../types/edition";
|
||||
interface GameInvite {
|
||||
inviteId: string;
|
||||
from: string | { displayName: string };
|
||||
hostIp: string;
|
||||
hostPort: number;
|
||||
hostName: string;
|
||||
signalingSessionId?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function ChooseInstanceModal({
|
||||
isOpen,
|
||||
|
|
@ -26,9 +35,8 @@ export default function ChooseInstanceModal({
|
|||
const [error, setError] = useState<string>("");
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
const validInstances = editions.filter((e: Edition) =>
|
||||
installs.includes(e.instanceId)
|
||||
installs.includes(e.instanceId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -52,29 +60,33 @@ export default function ChooseInstanceModal({
|
|||
playPressSound();
|
||||
setIsJoining(true);
|
||||
setError("");
|
||||
setStatus("Accepting invite...");
|
||||
setStatus("Launching game...");
|
||||
try {
|
||||
const inviteData = await lceLiveService.acceptGameInvite(invite.inviteId) as Record<string, unknown>;
|
||||
const fromIp = typeof invite.from !== 'string' ? (invite.from as unknown as Record<string, string>).hostIp : undefined;
|
||||
const hostIp: string = (inviteData.hostIp as string) || fromIp || invite.hostIp;
|
||||
const hostPort: number = (inviteData.hostPort as number) || invite.hostPort;
|
||||
const sessionId = (inviteData.signalingSessionId as string) || invite.signalingSessionId || "";
|
||||
|
||||
const sessionId = invite.signalingSessionId || "";
|
||||
if (sessionId) {
|
||||
setStatus("Connecting via relay...");
|
||||
const accessToken = lceLiveService.accessToken ?? "";
|
||||
const accessToken = lceOnlineService.accessToken ?? "";
|
||||
await TauriService.startRelayProxy(accessToken, sessionId);
|
||||
setStatus("Launching game...");
|
||||
await TauriService.launchGame(
|
||||
selectedInstance,
|
||||
[{ name: invite.hostName || "LCELive Game", ip: "127.0.0.1", port: 61000 }],
|
||||
["-ip", "127.0.0.1", "-port", "61000", "-quitondisconnect"]
|
||||
[
|
||||
{
|
||||
name: invite.hostName || "LCE Online Game",
|
||||
ip: "127.0.0.1",
|
||||
port: 61000,
|
||||
},
|
||||
],
|
||||
["-ip", "127.0.0.1", "-port", "61000", "-quitondisconnect"],
|
||||
);
|
||||
} else {
|
||||
setStatus("Launching game...");
|
||||
await TauriService.stopAllProxies();
|
||||
await TauriService.launchGame(selectedInstance, [
|
||||
{ name: invite.hostName || "LCELive Game", ip: hostIp, port: hostPort }
|
||||
{
|
||||
name: invite.hostName || "LCE Online Game",
|
||||
ip: invite.hostIp,
|
||||
port: invite.hostPort,
|
||||
},
|
||||
]);
|
||||
}
|
||||
onClose();
|
||||
|
|
@ -102,13 +114,18 @@ export default function ChooseInstanceModal({
|
|||
setFocusIndex((prev) => (prev - 1 + max) % max);
|
||||
} else if (e.key === "Enter") {
|
||||
if (focusIndex === 0 && validInstances.length > 0) {
|
||||
const currentIdx = validInstances.findIndex((i: Edition) => i.instanceId === selectedInstance);
|
||||
const currentIdx = validInstances.findIndex(
|
||||
(i: Edition) => i.instanceId === selectedInstance,
|
||||
);
|
||||
const next = (currentIdx + 1) % validInstances.length;
|
||||
setSelectedInstance(validInstances[next].instanceId);
|
||||
playPressSound();
|
||||
} else if (focusIndex === 1 && !isJoining) {
|
||||
handleJoin();
|
||||
} else if (focusIndex === (validInstances.length > 0 ? 2 : 1) && !isJoining) {
|
||||
} else if (
|
||||
focusIndex === (validInstances.length > 0 ? 2 : 1) &&
|
||||
!isJoining
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
|
@ -118,8 +135,11 @@ export default function ChooseInstanceModal({
|
|||
}, [isOpen, focusIndex, selectedInstance, validInstances, isJoining]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostName = invite ? (typeof invite.from === 'string' ? invite.from : invite.from.displayName) : "";
|
||||
const hostName = invite
|
||||
? typeof invite.from === "string"
|
||||
? invite.from
|
||||
: invite.from.displayName
|
||||
: "";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -146,25 +166,41 @@ export default function ChooseInstanceModal({
|
|||
</p>
|
||||
|
||||
{validInstances.length > 0 ? (
|
||||
<div className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
|
||||
style={{ scrollbarWidth: "thin", scrollbarColor: "#373737 transparent" }}>
|
||||
<div
|
||||
className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
|
||||
style={{
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "#373737 transparent",
|
||||
}}
|
||||
>
|
||||
{validInstances.map((inst: Edition) => {
|
||||
const isSelected = selectedInstance === inst.instanceId;
|
||||
return (
|
||||
<div
|
||||
key={inst.instanceId}
|
||||
onClick={() => { playPressSound(); setSelectedInstance(inst.instanceId); }}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setSelectedInstance(inst.instanceId);
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
className={`w-full px-4 py-3 cursor-pointer flex items-center gap-3 transition-all outline-none border-none ${isSelected ? "bg-white/15 border-l-4 border-[#FFFF55]" : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent"}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-[#FFFF55]" />}
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="w-2 h-2 rounded-full bg-[#FFFF55]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white text-lg font-bold mc-text-shadow">{inst.name}</span>
|
||||
<span className="text-white text-lg font-bold mc-text-shadow">
|
||||
{inst.name}
|
||||
</span>
|
||||
{inst.selectedBranch && (
|
||||
<span className="text-gray-400 text-xs">{inst.selectedBranch}</span>
|
||||
<span className="text-gray-400 text-xs">
|
||||
{inst.selectedBranch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -189,10 +225,15 @@ export default function ChooseInstanceModal({
|
|||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
setFocusIndex(cancelIdx);
|
||||
}}
|
||||
onClick={() => { playBackSound(); onClose(); }}
|
||||
onClick={() => {
|
||||
playBackSound();
|
||||
onClose();
|
||||
}}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${(() => {
|
||||
const cancelIdx = validInstances.length > 0 ? 2 : 1;
|
||||
return focusIndex === cancelIdx ? "text-[#FFFF55]" : "text-white";
|
||||
return focusIndex === cancelIdx
|
||||
? "text-[#FFFF55]"
|
||||
: "text-white";
|
||||
})()}`}
|
||||
style={{
|
||||
backgroundImage: (() => {
|
||||
|
|
@ -213,9 +254,10 @@ export default function ChooseInstanceModal({
|
|||
onClick={handleJoin}
|
||||
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
|
||||
style={{
|
||||
backgroundImage: focusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundImage:
|
||||
focusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
|
|
@ -232,7 +274,9 @@ export default function ChooseInstanceModal({
|
|||
</h2>
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-white text-lg mc-text-shadow text-center">{status}</p>
|
||||
<p className="text-white text-lg mc-text-shadow text-center">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
|
||||
|
|
|
|||
|
|
@ -1,74 +1,49 @@
|
|||
import { useState, useEffect, useRef, useMemo, useCallback, memo } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
useAudio,
|
||||
useGame,
|
||||
} from "../../context/LauncherContext";
|
||||
import {
|
||||
lceLiveService,
|
||||
LceLiveAccount,
|
||||
FriendRequest,
|
||||
GameInvite,
|
||||
DeviceLinkStartResponse,
|
||||
} from "../../services/LceLiveService";
|
||||
import { useUI, useConfig, useAudio } from "../../context/LauncherContext";
|
||||
import { lceOnlineService } from "../../services/LceOnlineService";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import ChooseInstanceModal from "../modals/ChooseInstanceModal";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
interface LceLiveViewProps {
|
||||
interface LceOnlineViewProps {
|
||||
addFriendTarget?: string | null;
|
||||
onClearAddFriendTarget?: () => void;
|
||||
}
|
||||
const LceLiveView = memo(function LceLiveView({
|
||||
const LceOnlineView = memo(function LceOnlineView({
|
||||
addFriendTarget,
|
||||
onClearAddFriendTarget,
|
||||
}: LceLiveViewProps) {
|
||||
}: LceOnlineViewProps) {
|
||||
const { setActiveView } = useUI();
|
||||
const { animationsEnabled } = useConfig();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const { editions, installs } = useGame();
|
||||
const [isSignedIn, setIsSignedIn] = useState(lceLiveService.signedIn);
|
||||
const [currentTab, setCurrentTab] = useState<
|
||||
"friends" | "requests" | "invites" | "device_link"
|
||||
>("friends");
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(0);
|
||||
const [acceptInvite, setAcceptInvite] = useState<GameInvite | null>(null);
|
||||
const [friends, setFriends] = useState<LceLiveAccount[]>([]);
|
||||
const [incomingReqs, setIncomingReqs] = useState<FriendRequest[]>([]);
|
||||
const [outgoingReqs, setOutgoingReqs] = useState<FriendRequest[]>([]);
|
||||
const [invites, setInvites] = useState<GameInvite[]>([]);
|
||||
const [linkData, setLinkData] = useState<DeviceLinkStartResponse | null>(
|
||||
null,
|
||||
const [isSignedIn, setIsSignedIn] = useState(lceOnlineService.signedIn);
|
||||
const [currentTab, setCurrentTab] = useState<"friends" | "requests">(
|
||||
"friends",
|
||||
);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const [focusIndex, setFocusIndex] = useState<number | null>(0);
|
||||
const [friends, setFriends] = useState<string[]>([]);
|
||||
const [incomingReqs, setIncomingReqs] = useState<string[]>([]);
|
||||
const [outgoingReqs, setOutgoingReqs] = useState<string[]>([]);
|
||||
const [isHosting, setIsHosting] = useState(false);
|
||||
const [_hostStatus, setHostStatus] = useState("");
|
||||
const [hostIp, setHostIp] = useState("");
|
||||
const [hostPort, setHostPort] = useState(19132);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const [invitedFriends, setInvitedFriends] = useState<Set<string>>(new Set());
|
||||
const [showHostMethodPicker, setShowHostMethodPicker] = useState(false);
|
||||
const [isAddingFriend, setIsAddingFriend] = useState(false);
|
||||
const [addFriendUsername, setAddFriendUsername] = useState("");
|
||||
const addFriendInputRef = useRef<HTMLInputElement>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>("");
|
||||
const [authMode, setAuthMode] = useState<"login" | "register">("login");
|
||||
const [authUsername, setAuthUsername] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [isAuthLoading, setIsAuthLoading] = useState(false);
|
||||
const authUserRef = useRef<HTMLInputElement>(null);
|
||||
const authPassRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fetchSocialData = async () => {
|
||||
if (!lceLiveService.signedIn) return;
|
||||
if (!lceOnlineService.signedIn) return;
|
||||
try {
|
||||
const [f, reqs, invs] = await Promise.all([
|
||||
lceLiveService.getFriends(),
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites(),
|
||||
]);
|
||||
setFriends(f);
|
||||
setIncomingReqs(reqs.incoming);
|
||||
setOutgoingReqs(reqs.outgoing);
|
||||
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
|
||||
const lists = await lceOnlineService.getSocialLists();
|
||||
setFriends(lists.friends);
|
||||
setIncomingReqs(lists.requests);
|
||||
setOutgoingReqs([]);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
}
|
||||
|
|
@ -76,84 +51,21 @@ const LceLiveView = memo(function LceLiveView({
|
|||
|
||||
useEffect(() => {
|
||||
if (isSignedIn) {
|
||||
if (currentTab === "device_link") setCurrentTab("friends");
|
||||
fetchSocialData();
|
||||
const pollInvites = setInterval(async () => {
|
||||
try {
|
||||
const invs = await lceLiveService.getGameInvites();
|
||||
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
|
||||
} catch (e) {
|
||||
console.warn("Failed to poll invites", e);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(pollInvites);
|
||||
} else {
|
||||
setCurrentTab("device_link");
|
||||
}
|
||||
}, [isSignedIn, currentTab]);
|
||||
}, [isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTab !== "device_link") return;
|
||||
let mounted = true;
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const startLink = async () => {
|
||||
try {
|
||||
if (!linkData) {
|
||||
const data = await lceLiveService.startDeviceLink();
|
||||
if (mounted) setLinkData(data);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (mounted) setLinkError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
startLink();
|
||||
if (linkData?.deviceCode) {
|
||||
pollInterval = setInterval(
|
||||
async () => {
|
||||
try {
|
||||
const res = await lceLiveService.pollDeviceLink(
|
||||
linkData.deviceCode,
|
||||
);
|
||||
if (res.isLinked && mounted) {
|
||||
setIsSignedIn(true);
|
||||
setLinkData(null);
|
||||
if (pollInterval !== null) clearInterval(pollInterval);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn("Poll failed", e);
|
||||
}
|
||||
},
|
||||
Math.max(linkData.intervalSeconds * 1000, 2000),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollInterval !== null) clearInterval(pollInterval);
|
||||
};
|
||||
}, [currentTab, linkData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkData?.verificationUri || !linkData?.userCode) return;
|
||||
const authUrl = `${linkData.verificationUri}?code=${linkData.userCode}`;
|
||||
QRCode.toDataURL(authUrl, { width: 200, margin: 1 }, (err, url) => {
|
||||
if (!err) setQrDataUrl(url);
|
||||
});
|
||||
}, [linkData]);
|
||||
|
||||
const openAuthUrl = useCallback(() => {
|
||||
if (!linkData?.verificationUri || !linkData?.userCode) return;
|
||||
const authUrl = `${linkData.verificationUri}?code=${linkData.userCode}`;
|
||||
TauriService.openUrl(authUrl);
|
||||
}, [linkData]);
|
||||
if (!addFriendTarget) return;
|
||||
setCurrentTab("friends");
|
||||
handleAction(() => lceOnlineService.sendFriendRequest(addFriendTarget));
|
||||
onClearAddFriendTarget?.();
|
||||
}, [addFriendTarget, onClearAddFriendTarget]);
|
||||
|
||||
const handleLogout = () => {
|
||||
playPressSound();
|
||||
lceLiveService.logoutLocal();
|
||||
lceOnlineService.logoutLocal();
|
||||
setIsSignedIn(false);
|
||||
setLinkData(null);
|
||||
};
|
||||
|
||||
const handleAction = async (action: () => Promise<void>) => {
|
||||
|
|
@ -166,54 +78,15 @@ const LceLiveView = memo(function LceLiveView({
|
|||
}
|
||||
};
|
||||
|
||||
const handleStartHosting = () => {
|
||||
const handleStartHosting = async () => {
|
||||
playPressSound();
|
||||
setShowHostMethodPicker(true);
|
||||
setFocusIndex(0);
|
||||
};
|
||||
|
||||
const handleHostDirect = async () => {
|
||||
setShowHostMethodPicker(false);
|
||||
setFocusIndex(0);
|
||||
setIsDiscovering(true);
|
||||
setHostStatus("Discovering external IP...");
|
||||
setHostStatus("Starting relay...");
|
||||
try {
|
||||
const endpoint = await TauriService.stunDiscover();
|
||||
setHostIp(endpoint.ip);
|
||||
setHostPort(25565);
|
||||
setIsHosting(true);
|
||||
setHostStatus(`Hosting at ${endpoint.ip}:25565`);
|
||||
setInvitedFriends(new Set());
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: typeof e === "string"
|
||||
? e
|
||||
: "Unknown error";
|
||||
setErrorModal("STUN discovery failed: " + msg);
|
||||
setHostStatus("");
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostRelay = async () => {
|
||||
setShowHostMethodPicker(false);
|
||||
setFocusIndex(0);
|
||||
setIsDiscovering(true);
|
||||
setHostStatus("Discovering external IP for invite...");
|
||||
try {
|
||||
const endpoint = await TauriService.stunDiscover();
|
||||
setHostIp(endpoint.ip);
|
||||
setHostPort(25565);
|
||||
} catch {
|
||||
setHostIp("127.0.0.1");
|
||||
setHostPort(25565);
|
||||
}
|
||||
await TauriService.stunDiscover();
|
||||
} catch {}
|
||||
setIsHosting(true);
|
||||
setHostStatus("Relay ready - invite friends to activate");
|
||||
setInvitedFriends(new Set());
|
||||
setHostStatus("Relay ready");
|
||||
setIsDiscovering(false);
|
||||
};
|
||||
|
||||
|
|
@ -221,59 +94,33 @@ const LceLiveView = memo(function LceLiveView({
|
|||
playPressSound();
|
||||
try {
|
||||
await TauriService.stopAllProxies();
|
||||
await lceLiveService.deactivateGameInvites();
|
||||
} catch (e: unknown) {
|
||||
console.warn("Stop hosting failed", e);
|
||||
}
|
||||
setIsHosting(false);
|
||||
setHostStatus("");
|
||||
setHostIp("");
|
||||
setInvitedFriends(new Set());
|
||||
};
|
||||
|
||||
const handleInviteFriend = async (friend: LceLiveAccount) => {
|
||||
playPressSound();
|
||||
const name = lceLiveService.displayUsername;
|
||||
const hostUsername = lceLiveService.account?.username ?? name;
|
||||
const handleAuth = async () => {
|
||||
if (!authUsername.trim() || !authPassword.trim()) return;
|
||||
setIsAuthLoading(true);
|
||||
try {
|
||||
await lceLiveService.sendGameInvite(
|
||||
friend.accountId,
|
||||
hostIp,
|
||||
hostPort,
|
||||
name,
|
||||
hostUsername,
|
||||
);
|
||||
setInvitedFriends((prev) => new Set(prev).add(friend.accountId));
|
||||
setHostStatus("Connecting relay...");
|
||||
TauriService.startHostRelay(
|
||||
lceLiveService.accessToken ?? "",
|
||||
25565,
|
||||
)
|
||||
.then(() => setHostStatus("Relay active"))
|
||||
.catch((relayErr: unknown) => {
|
||||
const relayMsg =
|
||||
relayErr instanceof Error
|
||||
? relayErr.message
|
||||
: typeof relayErr === "string"
|
||||
? relayErr
|
||||
: "Unknown error";
|
||||
console.warn("Relay failed:", relayMsg);
|
||||
setHostStatus("Relay disconnected");
|
||||
});
|
||||
if (authMode === "login") {
|
||||
await lceOnlineService.login(authUsername.trim(), authPassword);
|
||||
} else {
|
||||
await lceOnlineService.register(authUsername.trim(), authPassword);
|
||||
}
|
||||
setIsSignedIn(true);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: typeof e === "string"
|
||||
? e
|
||||
: "Unknown error";
|
||||
setErrorModal("Failed to send invite: " + msg);
|
||||
setErrorModal(e instanceof Error ? e.message : "Authentication failed");
|
||||
} finally {
|
||||
setIsAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
type: "button" | "friend" | "request_in" | "request_out" | "invite";
|
||||
type: "button" | "friend" | "request_in" | "request_out";
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClickSecondary?: () => void;
|
||||
|
|
@ -281,20 +128,8 @@ const LceLiveView = memo(function LceLiveView({
|
|||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [];
|
||||
if (currentTab === "device_link") {
|
||||
if (linkData) {
|
||||
items.push({
|
||||
id: "link_retry",
|
||||
type: "button",
|
||||
label: "Restart Link",
|
||||
onClick: () => {
|
||||
setLinkData(null);
|
||||
playPressSound();
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (currentTab === "friends") {
|
||||
if (!isDiscovering && !showHostMethodPicker) {
|
||||
if (currentTab === "friends") {
|
||||
if (!isDiscovering) {
|
||||
if (!isHosting) {
|
||||
items.push({
|
||||
id: "host_game",
|
||||
|
|
@ -329,56 +164,31 @@ const LceLiveView = memo(function LceLiveView({
|
|||
});
|
||||
friends.forEach((f) => {
|
||||
items.push({
|
||||
id: `friend_${f.accountId}`,
|
||||
id: `friend_${f}`,
|
||||
type: "friend",
|
||||
label: f.displayName,
|
||||
onClick: isHosting
|
||||
? () => handleInviteFriend(f)
|
||||
: () =>
|
||||
handleAction(() => lceLiveService.removeFriend(f.accountId)),
|
||||
onClickSecondary: isHosting
|
||||
? () => handleAction(() => lceLiveService.removeFriend(f.accountId))
|
||||
: undefined,
|
||||
label: f,
|
||||
onClick: () => handleAction(() => lceOnlineService.removeFriend(f)),
|
||||
});
|
||||
});
|
||||
} else if (currentTab === "requests") {
|
||||
incomingReqs.forEach((r) => {
|
||||
items.push({
|
||||
id: `req_in_${r.username}`,
|
||||
id: `req_in_${r}`,
|
||||
type: "request_in",
|
||||
label: r.displayName,
|
||||
label: r,
|
||||
onClick: () =>
|
||||
handleAction(() => lceLiveService.sendFriendRequest(r.username)),
|
||||
handleAction(() => lceOnlineService.acceptFriendRequest(r)),
|
||||
onClickSecondary: () =>
|
||||
handleAction(() =>
|
||||
lceLiveService.declineFriendRequest(r.accountId),
|
||||
),
|
||||
handleAction(() => lceOnlineService.declineFriendRequest(r)),
|
||||
});
|
||||
});
|
||||
outgoingReqs.forEach((r) => {
|
||||
items.push({
|
||||
id: `req_out_${r.username}`,
|
||||
id: `req_out_${r}`,
|
||||
type: "request_out",
|
||||
label: r.displayName,
|
||||
label: r,
|
||||
onClick: () =>
|
||||
handleAction(() =>
|
||||
lceLiveService.declineFriendRequest(r.accountId),
|
||||
),
|
||||
});
|
||||
});
|
||||
} else if (currentTab === "invites") {
|
||||
invites.forEach((inv) => {
|
||||
items.push({
|
||||
id: `inv_${inv.inviteId}`,
|
||||
type: "invite",
|
||||
label:
|
||||
typeof inv.from === "string" ? "Unknown" : inv.from.displayName,
|
||||
onClick: () => {
|
||||
playPressSound();
|
||||
setAcceptInvite(inv);
|
||||
},
|
||||
onClickSecondary: () =>
|
||||
handleAction(() => lceLiveService.declineGameInvite(inv.inviteId)),
|
||||
handleAction(() => lceOnlineService.declineFriendRequest(r)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -389,28 +199,12 @@ const LceLiveView = memo(function LceLiveView({
|
|||
friends,
|
||||
incomingReqs,
|
||||
outgoingReqs,
|
||||
invites,
|
||||
linkData,
|
||||
playPressSound,
|
||||
isHosting,
|
||||
isDiscovering,
|
||||
showHostMethodPicker,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!addFriendTarget) return;
|
||||
setCurrentTab("friends");
|
||||
handleAction(() =>
|
||||
lceLiveService.sendFriendRequest(addFriendTarget),
|
||||
);
|
||||
onClearAddFriendTarget?.();
|
||||
}, [addFriendTarget, onClearAddFriendTarget]);
|
||||
|
||||
const tabs: ("friends" | "requests" | "invites")[] = [
|
||||
"friends",
|
||||
"requests",
|
||||
"invites",
|
||||
];
|
||||
const tabs: ("friends" | "requests")[] = ["friends", "requests"];
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (errorModal) {
|
||||
|
|
@ -427,7 +221,7 @@ const LceLiveView = memo(function LceLiveView({
|
|||
} else if (e.key === "Enter") {
|
||||
if (addFriendUsername.trim() !== "") {
|
||||
handleAction(() =>
|
||||
lceLiveService.sendFriendRequest(addFriendUsername.trim()),
|
||||
lceOnlineService.sendFriendRequest(addFriendUsername.trim()),
|
||||
);
|
||||
setIsAddingFriend(false);
|
||||
}
|
||||
|
|
@ -435,23 +229,24 @@ const LceLiveView = memo(function LceLiveView({
|
|||
return;
|
||||
}
|
||||
|
||||
if (showHostMethodPicker) {
|
||||
if (!isSignedIn) {
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
setShowHostMethodPicker(false);
|
||||
setFocusIndex(0);
|
||||
playBackSound();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
setFocusIndex((prev) => (prev === null || prev >= 2 ? 0 : prev + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
setFocusIndex((prev) => (prev === null || prev <= 0 ? 2 : prev - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
if (focusIndex === 0) handleHostRelay();
|
||||
else if (focusIndex === 1) handleHostDirect();
|
||||
else if (focusIndex === 2) {
|
||||
setShowHostMethodPicker(false);
|
||||
setFocusIndex(0);
|
||||
playBackSound();
|
||||
setActiveView("main");
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
if (document.activeElement === authUserRef.current) {
|
||||
authPassRef.current?.focus();
|
||||
} else {
|
||||
authUserRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
handleAuth();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -462,22 +257,20 @@ const LceLiveView = memo(function LceLiveView({
|
|||
return;
|
||||
}
|
||||
|
||||
if (currentTab !== "device_link") {
|
||||
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 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;
|
||||
|
|
@ -506,9 +299,10 @@ const LceLiveView = memo(function LceLiveView({
|
|||
isAddingFriend,
|
||||
addFriendUsername,
|
||||
errorModal,
|
||||
showHostMethodPicker,
|
||||
handleHostDirect,
|
||||
handleHostRelay,
|
||||
handleAuth,
|
||||
isSignedIn,
|
||||
authUsername,
|
||||
authPassword,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -530,52 +324,65 @@ const LceLiveView = memo(function LceLiveView({
|
|||
}
|
||||
}
|
||||
}, [focusIndex, isAddingFriend]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (currentTab === "device_link") {
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-center py-12">
|
||||
{!linkData ? (
|
||||
<p className="text-lg text-[#2a2a2a] font-bold">
|
||||
{linkError || "Starting device link..."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-start justify-center gap-10 w-full max-w-2xl">
|
||||
<div className="flex flex-col items-center space-y-4 flex-1 min-w-0">
|
||||
<p className="text-lg text-[#2a2a2a] font-bold">
|
||||
Open this link in your browser:
|
||||
</p>
|
||||
<p className="text-[#111] text-base font-bold tracking-widest break-all bg-black/10 px-4 py-2 rounded shadow-inner">
|
||||
{linkData.verificationUri}
|
||||
</p>
|
||||
<p className="text-lg text-[#2a2a2a] font-bold">
|
||||
And enter the code:
|
||||
</p>
|
||||
<p className="text-[#111] text-4xl tracking-[0.2em] font-bold bg-black/10 px-6 py-3 rounded shadow-inner">
|
||||
{linkData.userCode}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 shrink-0">
|
||||
{qrDataUrl && (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code"
|
||||
className="w-48 h-48 image-rendering-pixelated"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={openAuthUrl}
|
||||
className="h-10 px-6 flex items-center justify-center text-white mc-text-shadow text-base font-bold uppercase tracking-widest outline-none border-none hover:text-[#FFFF55] transition-colors"
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Open in Browser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-8 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
|
||||
{authMode === "login" ? "Sign In" : "Register"}
|
||||
</h2>
|
||||
<input
|
||||
ref={authUserRef}
|
||||
type="text"
|
||||
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full max-w-sm text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-4 mc-text-shadow"
|
||||
placeholder="Username"
|
||||
value={authUsername}
|
||||
onChange={(e) => setAuthUsername(e.target.value)}
|
||||
disabled={isAuthLoading}
|
||||
/>
|
||||
<input
|
||||
ref={authPassRef}
|
||||
type="password"
|
||||
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full max-w-sm text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-6 mc-text-shadow"
|
||||
placeholder="Password"
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
disabled={isAuthLoading}
|
||||
/>
|
||||
<div className="flex gap-4 w-full max-w-sm">
|
||||
<button
|
||||
className={`h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest hover:text-[#FFFF55] outline-none border-none ${isAuthLoading ? "opacity-50 pointer-events-none" : ""}`}
|
||||
style={{
|
||||
backgroundImage: "url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={handleAuth}
|
||||
disabled={isAuthLoading}
|
||||
>
|
||||
{isAuthLoading
|
||||
? "..."
|
||||
: authMode === "login"
|
||||
? "Login"
|
||||
: "Register"}
|
||||
</button>
|
||||
<button
|
||||
className={`h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest hover:text-[#FFFF55] outline-none border-none ${isAuthLoading ? "opacity-50 pointer-events-none" : ""}`}
|
||||
style={{
|
||||
backgroundImage: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={() => {
|
||||
playPressSound();
|
||||
setAuthMode(authMode === "login" ? "register" : "login");
|
||||
}}
|
||||
disabled={isAuthLoading}
|
||||
>
|
||||
{authMode === "login" ? "Register" : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -614,11 +421,7 @@ const LceLiveView = memo(function LceLiveView({
|
|||
<div className="flex flex-col flex-1 bg-black/5 shadow-inner rounded overflow-hidden border-4 border-[#222]">
|
||||
<div className="bg-black/10 px-4 py-3 text-[#2a2a2a] font-bold tracking-widest uppercase border-b-4 border-[#222] flex justify-between shadow-sm z-10">
|
||||
<span>
|
||||
{currentTab === "friends"
|
||||
? "Joinable Friends"
|
||||
: currentTab === "requests"
|
||||
? "Pending Requests"
|
||||
: "Game Invites"}
|
||||
{currentTab === "friends" ? "Friends" : "Pending Requests"}
|
||||
</span>
|
||||
<span className="text-[#111]">{listItems.length}</span>
|
||||
</div>
|
||||
|
|
@ -646,28 +449,10 @@ const LceLiveView = memo(function LceLiveView({
|
|||
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-[#555] text-base font-bold truncate">
|
||||
@
|
||||
{menuItems.find((m) => m.id === item.id)?.type ===
|
||||
"friend"
|
||||
? friends.find(
|
||||
(f) => `friend_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: item.type === "request_in"
|
||||
? incomingReqs.find(
|
||||
(f) => `req_in_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: item.type === "request_out"
|
||||
? outgoingReqs.find(
|
||||
(f) =>
|
||||
`req_out_${f.accountId}` === item.id,
|
||||
)?.username
|
||||
: "Invite"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 pr-2 shrink-0">
|
||||
{item.type === "friend" && !isHosting && (
|
||||
{item.type === "friend" && (
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
|
|
@ -681,41 +466,6 @@ const LceLiveView = memo(function LceLiveView({
|
|||
REMOVE
|
||||
</button>
|
||||
)}
|
||||
{item.type === "friend" && isHosting && (
|
||||
<>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/button_highlighted.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{invitedFriends.has(
|
||||
item.id.replace("friend_", ""),
|
||||
)
|
||||
? "INVITED"
|
||||
: "INVITE"}
|
||||
</button>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClickSecondary?.();
|
||||
}}
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{item.type === "request_out" && (
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
|
||||
|
|
@ -730,8 +480,7 @@ const LceLiveView = memo(function LceLiveView({
|
|||
CANCEL
|
||||
</button>
|
||||
)}
|
||||
{(item.type === "request_in" ||
|
||||
item.type === "invite") && (
|
||||
{item.type === "request_in" && (
|
||||
<>
|
||||
<button
|
||||
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow ${isFocused ? "text-white shadow-md" : "text-gray-300"}`}
|
||||
|
|
@ -773,6 +522,7 @@ const LceLiveView = memo(function LceLiveView({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
|
|
@ -784,7 +534,7 @@ const LceLiveView = memo(function LceLiveView({
|
|||
className="flex flex-col items-center justify-center w-full h-full absolute inset-0 outline-none p-12"
|
||||
>
|
||||
<div className="w-full max-w-5xl h-full flex flex-col mt-[4vh] mb-[4vh] relative drop-shadow-2xl">
|
||||
{currentTab !== "device_link" && (
|
||||
{isSignedIn && (
|
||||
<div
|
||||
className="flex z-10 space-x-2 px-12 relative w-full items-end"
|
||||
style={{ marginBottom: "-4px" }}
|
||||
|
|
@ -815,13 +565,6 @@ const LceLiveView = memo(function LceLiveView({
|
|||
{incomingReqs.length}
|
||||
</span>
|
||||
)}
|
||||
{t === "invites" && invites.length > 0 && (
|
||||
<span
|
||||
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#30872a] border-[#1b5e16]" : "bg-[#23681d] border-[#111]"}`}
|
||||
>
|
||||
{invites.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -840,14 +583,6 @@ const LceLiveView = memo(function LceLiveView({
|
|||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
<div className="flex justify-center pt-4 pb-2">
|
||||
<img
|
||||
src="/images/lcelive.png"
|
||||
alt="LCELive"
|
||||
className="h-5 opacity-70 cursor-pointer"
|
||||
onClick={() => TauriService.openUrl("https://lcelive.co.uk")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
|
|
@ -889,7 +624,7 @@ const LceLiveView = memo(function LceLiveView({
|
|||
playPressSound();
|
||||
if (addFriendUsername.trim() !== "") {
|
||||
handleAction(() =>
|
||||
lceLiveService.sendFriendRequest(
|
||||
lceOnlineService.sendFriendRequest(
|
||||
addFriendUsername.trim(),
|
||||
),
|
||||
);
|
||||
|
|
@ -918,83 +653,6 @@ const LceLiveView = memo(function LceLiveView({
|
|||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showHostMethodPicker && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[105] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
|
||||
>
|
||||
<div
|
||||
className="relative w-[420px] p-8 flex flex-col items-center shadow-2xl gap-4"
|
||||
style={{
|
||||
backgroundImage: "url('/images/frame_background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-2 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
|
||||
Host Game
|
||||
</h2>
|
||||
<button
|
||||
data-index={0}
|
||||
onMouseEnter={() => setFocusIndex(0)}
|
||||
onClick={handleHostRelay}
|
||||
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 0 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 0
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Relay
|
||||
</button>
|
||||
<button
|
||||
data-index={1}
|
||||
onMouseEnter={() => setFocusIndex(1)}
|
||||
onClick={handleHostDirect}
|
||||
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 1
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Direct (STUN)
|
||||
</button>
|
||||
<button
|
||||
data-index={2}
|
||||
onMouseEnter={() => setFocusIndex(2)}
|
||||
onClick={() => {
|
||||
setShowHostMethodPicker(false);
|
||||
setFocusIndex(0);
|
||||
playBackSound();
|
||||
}}
|
||||
className={`w-full h-14 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none ${focusIndex === 2 ? "text-[#FFFF55] mc-text-shadow" : "text-white mc-text-shadow hover:text-[#FFFF55]"}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === 2
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorModal && (
|
||||
<motion.div
|
||||
|
|
@ -1032,21 +690,8 @@ const LceLiveView = memo(function LceLiveView({
|
|||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ChooseInstanceModal
|
||||
isOpen={acceptInvite !== null}
|
||||
onClose={() => {
|
||||
setAcceptInvite(null);
|
||||
fetchSocialData();
|
||||
}}
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
editions={editions}
|
||||
installs={installs}
|
||||
invite={acceptInvite}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export default LceLiveView;
|
||||
export default LceOnlineView;
|
||||
|
|
@ -62,7 +62,7 @@ export function useDiscordRPC({
|
|||
devtools: "Developing for LCE",
|
||||
skins: "Changing Skins",
|
||||
workshop: "Browsing Workshop",
|
||||
lcelive: "Browsing Friends",
|
||||
lceonline: "Browsing Friends",
|
||||
"pck-editor": "Editing a PCK file",
|
||||
"options-editor": "Editing Options Files",
|
||||
"arc-editor": "Editing an ARC file",
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { lceLiveService, type FriendRequest, type GameInvite } from "../services/LceLiveService";
|
||||
export function useLceLiveNotifications() {
|
||||
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
|
||||
const [gameInviteMessage, setGameInviteMessage] = useState<string | null>(null);
|
||||
const seenRequests = useRef<Set<string>>(new Set());
|
||||
const seenInvites = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let pollInterval: ReturnType<typeof setInterval>;
|
||||
const init = async () => {
|
||||
if (lceLiveService.signedIn) {
|
||||
try {
|
||||
await lceLiveService.refreshSession();
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (lceLiveService.signedIn) {
|
||||
try {
|
||||
const [reqs, invs] = await Promise.all([
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites()
|
||||
]);
|
||||
reqs.incoming.forEach((r: FriendRequest) => seenRequests.current.add(r.accountId));
|
||||
invs.filter((i: GameInvite) => i.status === "pending").forEach((i: GameInvite) => seenInvites.current.add(i.inviteId));
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
pollInterval = setInterval(async () => {
|
||||
if (!lceLiveService.signedIn) return;
|
||||
try {
|
||||
const [reqs, invs] = await Promise.all([
|
||||
lceLiveService.getPendingRequests(),
|
||||
lceLiveService.getGameInvites()
|
||||
]);
|
||||
|
||||
reqs.incoming.forEach((r: FriendRequest) => {
|
||||
if (!seenRequests.current.has(r.accountId)) {
|
||||
seenRequests.current.add(r.accountId);
|
||||
setFriendRequestMessage(`New request from ${r.displayName}`);
|
||||
}
|
||||
});
|
||||
|
||||
invs.filter((i: GameInvite) => i.status === "pending").forEach((i: GameInvite) => {
|
||||
if (!seenInvites.current.has(i.inviteId)) {
|
||||
seenInvites.current.add(i.inviteId);
|
||||
const fromName = typeof i.from === 'string' ? "Unknown" : i.from.displayName;
|
||||
setGameInviteMessage(`${fromName} invited you to play!`);
|
||||
}
|
||||
});
|
||||
} catch (e) { }
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
init();
|
||||
return () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
friendRequestMessage,
|
||||
gameInviteMessage,
|
||||
clearFriendRequestMessage: () => setFriendRequestMessage(null),
|
||||
clearGameInviteMessage: () => setGameInviteMessage(null),
|
||||
};
|
||||
}
|
||||
41
src/hooks/useLceOnlineNotifications.ts
Normal file
41
src/hooks/useLceOnlineNotifications.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
import { lceOnlineService } from "../services/LceOnlineService";
|
||||
export function useLceOnlineNotifications() {
|
||||
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
|
||||
const seenRequests = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let pollInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
const init = async () => {
|
||||
if (lceOnlineService.signedIn) {
|
||||
try {
|
||||
const lists = await lceOnlineService.getSocialLists();
|
||||
lists.requests.forEach((r: string) => seenRequests.current.add(r));
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
pollInterval = setInterval(async () => {
|
||||
if (!lceOnlineService.signedIn) return;
|
||||
try {
|
||||
const lists = await lceOnlineService.getSocialLists();
|
||||
lists.requests.forEach((r: string) => {
|
||||
if (!seenRequests.current.has(r)) {
|
||||
seenRequests.current.add(r);
|
||||
setFriendRequestMessage(`New request from ${r}`);
|
||||
}
|
||||
});
|
||||
} catch (e) { }
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
init();
|
||||
return () => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
friendRequestMessage,
|
||||
clearFriendRequestMessage: () => setFriendRequestMessage(null),
|
||||
};
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ import OptionsEditorView from "../components/views/OptionsEditorView";
|
|||
import ModelEditorView from "../components/views/ModelEditorView";
|
||||
import ScreenshotsView from "../components/views/ScreenshotsView";
|
||||
import SwfView from "../components/views/SwfView";
|
||||
import LceLiveView from "../components/views/LceLiveView";
|
||||
import LceOnlineView from "../components/views/LceOnlineView";
|
||||
import CreditsView from "../components/views/CreditsView";
|
||||
import SkinViewer from "../components/common/SkinViewer";
|
||||
import PanoramaBackground from "../components/common/PanoramaBackground";
|
||||
|
|
@ -35,7 +35,7 @@ import {
|
|||
useSkin,
|
||||
} from "../context/LauncherContext";
|
||||
import { TauriService } from "../services/TauriService";
|
||||
import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications";
|
||||
import { useLceOnlineNotifications } from "../hooks/useLceOnlineNotifications";
|
||||
import { usePluginViews } from "../plugins/PluginContext";
|
||||
import { usePlatform } from "../hooks/usePlatform";
|
||||
import { PluginManager } from "../plugins/PluginManager";
|
||||
|
|
@ -67,12 +67,10 @@ export default function App() {
|
|||
const game = useGame();
|
||||
const skin = useSkin();
|
||||
const { skinUrl, setSkinUrl, capeUrl } = skin;
|
||||
const notifications = useLceLiveNotifications();
|
||||
const notifications = useLceOnlineNotifications();
|
||||
const {
|
||||
friendRequestMessage,
|
||||
gameInviteMessage,
|
||||
clearFriendRequestMessage,
|
||||
clearGameInviteMessage,
|
||||
} = notifications;
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
const [isSetupChecked, setIsSetupChecked] = useState(false);
|
||||
|
|
@ -158,13 +156,13 @@ export default function App() {
|
|||
}
|
||||
|
||||
if (
|
||||
action === "lcelive" &&
|
||||
action === "lceonline" &&
|
||||
parts.length >= 2 &&
|
||||
parts[1] === "addfriend"
|
||||
) {
|
||||
const username = parsed.searchParams.get("username");
|
||||
if (username) {
|
||||
setActiveView("lcelive");
|
||||
setActiveView("lceonline");
|
||||
setAddFriendTarget(username);
|
||||
return;
|
||||
}
|
||||
|
|
@ -614,9 +612,9 @@ export default function App() {
|
|||
{activeView === "swf-editor" && (
|
||||
<SwfView key="swf-editor-view" />
|
||||
)}
|
||||
{activeView === "lcelive" && (
|
||||
<LceLiveView
|
||||
key="lcelive-view"
|
||||
{activeView === "lceonline" && (
|
||||
<LceOnlineView
|
||||
key="lceonline-view"
|
||||
addFriendTarget={addFriendTarget}
|
||||
onClearAddFriendTarget={() => setAddFriendTarget(null)}
|
||||
/>
|
||||
|
|
@ -662,22 +660,12 @@ export default function App() {
|
|||
onClose={clearFriendRequestMessage}
|
||||
onClick={() => {
|
||||
clearFriendRequestMessage();
|
||||
setActiveView("lcelive");
|
||||
setActiveView("lceonline");
|
||||
}}
|
||||
title="Friend Request"
|
||||
variant="update"
|
||||
/>
|
||||
|
||||
<AchievementToast
|
||||
message={gameInviteMessage}
|
||||
onClose={clearGameInviteMessage}
|
||||
onClick={() => {
|
||||
clearGameInviteMessage();
|
||||
setActiveView("lcelive");
|
||||
}}
|
||||
title="Game Invite"
|
||||
variant="update"
|
||||
/>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,384 +0,0 @@
|
|||
const LOCAL_STORAGE_KEY = "lcelive_session";
|
||||
const DEFAULT_BASE_URL = "https://api.lcelive.co.uk";
|
||||
const MAX_REFRESH_RETRIES = 1;
|
||||
import { TauriService } from "./TauriService";
|
||||
export interface LceLiveAccount {
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
account: LceLiveAccount;
|
||||
}
|
||||
|
||||
export interface DeviceLinkStartResponse {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete?: string;
|
||||
intervalSeconds: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
export interface DeviceLinkPollResponse {
|
||||
status: string;
|
||||
isLinked?: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
account?: LceLiveAccount;
|
||||
}
|
||||
|
||||
export interface GameInvite {
|
||||
inviteId: string;
|
||||
from: LceLiveAccount | string;
|
||||
hostIp: string;
|
||||
hostPort: number;
|
||||
hostName: string;
|
||||
signalingSessionId?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
accountId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface PendingRequests {
|
||||
incoming: FriendRequest[];
|
||||
outgoing: FriendRequest[];
|
||||
}
|
||||
|
||||
export class LceLiveService {
|
||||
private _session: SessionData | null = null;
|
||||
private baseUrl: string = DEFAULT_BASE_URL;
|
||||
private _refreshPromise: Promise<void> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadSession();
|
||||
}
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
get signedIn(): boolean {
|
||||
return this._session !== null;
|
||||
}
|
||||
|
||||
get account(): LceLiveAccount | null {
|
||||
return this._session?.account || null;
|
||||
}
|
||||
|
||||
get displayUsername(): string {
|
||||
if (!this._session) return "Not signed in";
|
||||
return (
|
||||
this._session.account.displayName ||
|
||||
this._session.account.username ||
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
get apiBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
get accessToken(): string | null {
|
||||
return this._session?.accessToken || null;
|
||||
}
|
||||
|
||||
public generateDeviceId(): string {
|
||||
let id = localStorage.getItem("lcelive_device_id");
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem("lcelive_device_id", id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public getDeviceName(): string {
|
||||
return "LCE Emerald Launcher";
|
||||
}
|
||||
|
||||
private loadSession() {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (data) {
|
||||
this._session = JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load LceLive session", e);
|
||||
}
|
||||
}
|
||||
|
||||
private saveSession() {
|
||||
if (this._session) {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this._session));
|
||||
} else {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T = any>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
authed: boolean = true,
|
||||
retryCount: number = 0,
|
||||
): Promise<T> {
|
||||
if (authed && this._session?.refreshToken && retryCount === 0) {
|
||||
try {
|
||||
await this.refreshSession(); //neo: i do this on every request only because it doesnt always return 401
|
||||
} catch (err) {
|
||||
this.logoutLocal();
|
||||
throw new Error("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "MCLCE-LceLive/1.0",
|
||||
};
|
||||
|
||||
if (body) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (authed && this._session?.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
|
||||
}
|
||||
|
||||
const bodyStr = body ? JSON.stringify(body) : null;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await TauriService.httpProxyRequest(
|
||||
method,
|
||||
`${this.baseUrl}${path}`,
|
||||
bodyStr,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Network error when calling ${path}: ${e}`);
|
||||
}
|
||||
|
||||
if (
|
||||
res.status === 401 &&
|
||||
authed &&
|
||||
this._session?.refreshToken &&
|
||||
retryCount < MAX_REFRESH_RETRIES
|
||||
) {
|
||||
try {
|
||||
await this.refreshSession();
|
||||
return this.request(method, path, body, authed, retryCount + 1);
|
||||
} catch (err) {
|
||||
this.logoutLocal();
|
||||
throw new Error("Session expired. Please log in again.");
|
||||
}
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = res.body ? JSON.parse(res.body) : {};
|
||||
} catch {
|
||||
data = { message: res.body };
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const errorMsg =
|
||||
data.message ||
|
||||
data.detail ||
|
||||
data.title ||
|
||||
data.error ||
|
||||
`HTTP ${res.status}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async startDeviceLink(): Promise<DeviceLinkStartResponse> {
|
||||
return this.request<DeviceLinkStartResponse>(
|
||||
"POST",
|
||||
"/api/auth/device/start",
|
||||
{
|
||||
deviceId: this.generateDeviceId(),
|
||||
deviceName: this.getDeviceName(),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async pollDeviceLink(deviceCode: string): Promise<DeviceLinkPollResponse> {
|
||||
const data = await this.request<DeviceLinkPollResponse>(
|
||||
"GET",
|
||||
`/api/auth/device/poll/${deviceCode}`,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
if (data.isLinked && data.accessToken) {
|
||||
this._session = {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken ?? "",
|
||||
account: data.account ?? {
|
||||
accountId: "",
|
||||
username: "",
|
||||
displayName: "",
|
||||
},
|
||||
};
|
||||
this.saveSession();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<void> {
|
||||
if (!this._session?.refreshToken) throw new Error("No refresh token");
|
||||
|
||||
if (this._refreshPromise) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
this._refreshPromise = (async () => {
|
||||
try {
|
||||
const data = await this.request(
|
||||
"POST",
|
||||
"/api/auth/refresh",
|
||||
{
|
||||
refreshToken: this._session!.refreshToken,
|
||||
},
|
||||
false,
|
||||
);
|
||||
this._session = {
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
account: data.account,
|
||||
};
|
||||
this.saveSession();
|
||||
} finally {
|
||||
this._refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (this._session?.refreshToken) {
|
||||
try {
|
||||
await this.request(
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
{
|
||||
refreshToken: this._session.refreshToken,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("Server logout failed", e);
|
||||
}
|
||||
}
|
||||
this.logoutLocal();
|
||||
}
|
||||
|
||||
logoutLocal(): void {
|
||||
this._session = null;
|
||||
this.saveSession();
|
||||
}
|
||||
|
||||
async getFriends(): Promise<LceLiveAccount[]> {
|
||||
const data = await this.request("GET", "/api/social/friends");
|
||||
return data.friends || [];
|
||||
}
|
||||
|
||||
async removeFriend(accountId: string): Promise<void> {
|
||||
await this.request("DELETE", `/api/social/friends/${accountId}`);
|
||||
}
|
||||
|
||||
async sendFriendRequest(username: string): Promise<void> {
|
||||
await this.request("POST", "/api/social/request", { username });
|
||||
}
|
||||
|
||||
async getPendingRequests(): Promise<PendingRequests> {
|
||||
const data = await this.request("GET", "/api/social/requests");
|
||||
return {
|
||||
incoming: (data.incoming || []).map((r: Record<string, string>) => ({
|
||||
accountId: r.requesterUserId || r.accountId || r.userId,
|
||||
username: r.requesterUsername || r.username,
|
||||
displayName: r.requesterDisplayName || r.displayName,
|
||||
})),
|
||||
outgoing: (data.outgoing || []).map((r: Record<string, string>) => ({
|
||||
accountId: r.targetUserId || r.accountId || r.userId,
|
||||
username: r.targetUsername || r.username,
|
||||
displayName: r.targetDisplayName || r.displayName,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async acceptFriendRequest(accountId: string): Promise<void> {
|
||||
await this.request("POST", `/api/social/requests/${accountId}/accept`, {});
|
||||
}
|
||||
|
||||
async declineFriendRequest(accountId: string): Promise<void> {
|
||||
await this.request("POST", `/api/social/requests/${accountId}/decline`, {});
|
||||
}
|
||||
|
||||
async getGameInvites(): Promise<GameInvite[]> {
|
||||
const data = await this.request("GET", "/api/sessions/invites");
|
||||
const incoming = data.incoming || [];
|
||||
return incoming.map((inv: Record<string, unknown>) => ({
|
||||
inviteId: inv.inviteId,
|
||||
from: {
|
||||
accountId: inv.senderAccountId,
|
||||
username: inv.senderUsername,
|
||||
displayName: inv.senderDisplayName,
|
||||
},
|
||||
hostIp: inv.hostIp,
|
||||
hostPort: inv.hostPort,
|
||||
hostName: inv.hostName,
|
||||
status: inv.status,
|
||||
signalingSessionId: inv.signalingSessionId,
|
||||
}));
|
||||
}
|
||||
|
||||
async sendGameInvite(
|
||||
recipientAccountId: string,
|
||||
hostIp: string,
|
||||
hostPort: number,
|
||||
hostName: string,
|
||||
signalingSessionId?: string,
|
||||
): Promise<void> {
|
||||
await this.request("POST", "/api/sessions/invites", {
|
||||
recipientAccountId,
|
||||
hostIp,
|
||||
hostPort,
|
||||
hostName,
|
||||
signalingSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptGameInvite(inviteId: string): Promise<Record<string, unknown>> {
|
||||
return this.request("POST", `/api/sessions/invites/${inviteId}/accept`, {});
|
||||
}
|
||||
|
||||
async declineGameInvite(inviteId: string): Promise<void> {
|
||||
await this.request("POST", `/api/sessions/invites/${inviteId}/decline`, {});
|
||||
}
|
||||
|
||||
async deactivateGameInvites(): Promise<void> {
|
||||
await this.request("POST", "/api/sessions/invites/deactivate", {});
|
||||
}
|
||||
|
||||
async requestJoinTicket(): Promise<string> {
|
||||
const data = await this.request("POST", "/api/sessions/ticket", {});
|
||||
return data.ticket;
|
||||
}
|
||||
|
||||
async validateJoinTicket(ticket: string): Promise<LceLiveAccount> {
|
||||
return this.request("POST", "/api/sessions/validate", { ticket }, false);
|
||||
}
|
||||
}
|
||||
|
||||
export const lceLiveService = new LceLiveService();
|
||||
209
src/services/LceOnlineService.ts
Normal file
209
src/services/LceOnlineService.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
const SESSION_KEY = "lceonline_session";
|
||||
const SOCIAL_BASE_URL = "https://social.mclegacyedition.xyz";
|
||||
const AUTH_BASE_URL = "https://auth.mclegacyedition.xyz"; //neo: yeah bro im hardcoding all three
|
||||
import { TauriService } from "./TauriService";
|
||||
export interface LceOnlineAccount {
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
accessToken: string;
|
||||
account: LceOnlineAccount;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export class LceOnlineService {
|
||||
private _session: SessionData | null = null;
|
||||
private baseUrl: string = SOCIAL_BASE_URL;
|
||||
|
||||
constructor() {
|
||||
this.loadSession();
|
||||
}
|
||||
|
||||
get signedIn(): boolean {
|
||||
return this._session !== null;
|
||||
}
|
||||
|
||||
get account(): LceOnlineAccount | null {
|
||||
return this._session?.account || null;
|
||||
}
|
||||
|
||||
get displayUsername(): string {
|
||||
if (!this._session) return "Not signed in";
|
||||
return (
|
||||
this._session.account.displayName ||
|
||||
this._session.account.username ||
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
get accessToken(): string | null {
|
||||
return this._session?.accessToken || null;
|
||||
}
|
||||
|
||||
logoutLocal(): void {
|
||||
this._session = null;
|
||||
this.saveSession();
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<void> {
|
||||
const res = await this.request<{ body: string }>(
|
||||
"POST",
|
||||
"/login",
|
||||
`${username}:${password}`,
|
||||
AUTH_BASE_URL,
|
||||
);
|
||||
console.log("auth login response", res);
|
||||
const pretoken =
|
||||
res && typeof res === "object" && "body" in res
|
||||
? (res as any).body
|
||||
: typeof res === "string"
|
||||
? res
|
||||
: "";
|
||||
const token = pretoken.split(":")[1];
|
||||
this.loginWithToken(token, username);
|
||||
}
|
||||
|
||||
async register(username: string, password: string): Promise<void> {
|
||||
const res = await this.request<{ body: string }>(
|
||||
"POST",
|
||||
"/register",
|
||||
`${username}:${password}`,
|
||||
AUTH_BASE_URL,
|
||||
);
|
||||
console.log("auth register response", res);
|
||||
const pretoken =
|
||||
res && typeof res === "object" && "body" in res
|
||||
? (res as any).body
|
||||
: typeof res === "string"
|
||||
? res
|
||||
: "";
|
||||
const token = pretoken.split(":")[1];
|
||||
this.loginWithToken(token, username);
|
||||
}
|
||||
|
||||
loginWithToken(token: string, username: string) {
|
||||
this._session = {
|
||||
accessToken: token,
|
||||
account: { username, displayName: username },
|
||||
};
|
||||
this.saveSession();
|
||||
}
|
||||
|
||||
private loadSession() {
|
||||
try {
|
||||
const data = localStorage.getItem(SESSION_KEY);
|
||||
if (data) {
|
||||
this._session = JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load LCE Online session", e);
|
||||
}
|
||||
}
|
||||
|
||||
private saveSession() {
|
||||
if (this._session) {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(this._session));
|
||||
} else {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T = any>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string | null,
|
||||
baseUrl?: string,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/plain, application/json",
|
||||
"User-Agent": "MCLCE-LCEOnline/1.0",
|
||||
};
|
||||
|
||||
if (body) {
|
||||
headers["Content-Type"] = "text/plain";
|
||||
}
|
||||
|
||||
if (this._session?.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
|
||||
}
|
||||
|
||||
const url = `${baseUrl || this.baseUrl}${path}`;
|
||||
let res;
|
||||
try {
|
||||
res = await TauriService.httpProxyRequest(
|
||||
method,
|
||||
url,
|
||||
body ?? null,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Network error when calling ${path}: ${e}`);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = res.body ? JSON.parse(res.body) : {};
|
||||
} catch {
|
||||
data = res.body ?? {};
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const errorMsg =
|
||||
data.message ||
|
||||
data.detail ||
|
||||
data.title ||
|
||||
data.error ||
|
||||
data ||
|
||||
`HTTP ${res.status}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getSocialLists(): Promise<{
|
||||
friends: string[];
|
||||
requests: string[];
|
||||
blocked: string[];
|
||||
}> {
|
||||
const raw: string = await this.request<string>(
|
||||
"POST",
|
||||
"/getSocialLists",
|
||||
null,
|
||||
);
|
||||
if (typeof raw !== "string") {
|
||||
return { friends: [], requests: [], blocked: [] };
|
||||
}
|
||||
const withoutPrefix = raw.startsWith("-") ? raw.slice(1) : raw;
|
||||
const parts = withoutPrefix.split("|");
|
||||
return {
|
||||
friends: parts[0] ? parts[0].split(",").filter(Boolean) : [],
|
||||
requests: parts[1] ? parts[1].split(",").filter(Boolean) : [],
|
||||
blocked: parts[2] ? parts[2].split(",").filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
|
||||
async sendFriendRequest(target: string): Promise<void> {
|
||||
await this.request("POST", "/sendrequest", target);
|
||||
}
|
||||
|
||||
async acceptFriendRequest(from: string): Promise<void> {
|
||||
await this.request("POST", "/acceptrequest", from);
|
||||
}
|
||||
|
||||
async declineFriendRequest(from: string): Promise<void> {
|
||||
await this.request("POST", "/declinerequest", from);
|
||||
}
|
||||
|
||||
async removeFriend(from: string): Promise<void> {
|
||||
await this.request("POST", "/removefriend", from);
|
||||
}
|
||||
}
|
||||
|
||||
export const lceOnlineService = new LceOnlineService();
|
||||
Loading…
Reference in a new issue