upd
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Config,
|
||||
PixelStreaming,
|
||||
} from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7";
|
||||
import type { AllSettings } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7";
|
||||
|
||||
export interface PixelStreamingWrapperProps {
|
||||
initialSettings?: Partial<AllSettings>;
|
||||
}
|
||||
|
||||
export const PixelStreamingWrapper = ({
|
||||
initialSettings,
|
||||
}: PixelStreamingWrapperProps) => {
|
||||
// A reference to parent div element that the Pixel Streaming library attaches into:
|
||||
const videoParent = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pixel streaming library instance is stored into this state variable after initialization:
|
||||
const [pixelStreaming, setPixelStreaming] = useState<PixelStreaming>();
|
||||
|
||||
// A boolean state variable that determines if the Click to play overlay is shown:
|
||||
const [clickToPlayVisible, setClickToPlayVisible] = useState(false);
|
||||
|
||||
// Run on component mount:
|
||||
useEffect(() => {
|
||||
if (videoParent.current) {
|
||||
// Attach Pixel Streaming library to videoParent element:
|
||||
const config = new Config({ initialSettings });
|
||||
const streaming = new PixelStreaming(config, {
|
||||
videoElementParent: videoParent.current,
|
||||
});
|
||||
|
||||
// register a playStreamRejected handler to show Click to play overlay if needed:
|
||||
streaming.addEventListener("playStreamRejected", () => {
|
||||
setClickToPlayVisible(true);
|
||||
});
|
||||
|
||||
// Save the library instance into component state so that it can be accessed later:
|
||||
setPixelStreaming(streaming);
|
||||
|
||||
// Clean up on component unmount:
|
||||
return () => {
|
||||
try {
|
||||
streaming.disconnect();
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
ref={videoParent}
|
||||
/>
|
||||
{clickToPlayVisible && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
pixelStreaming?.play();
|
||||
setClickToPlayVisible(false);
|
||||
}}
|
||||
>
|
||||
<div>Click to play</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+6
-5
@@ -5,6 +5,7 @@ import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import SessionPage from "./pages/SessionPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
import TestPage from "./pages/TestPage";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
@@ -37,13 +38,13 @@ const router = createBrowserRouter([
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/test",
|
||||
element: <TestPage />,
|
||||
},
|
||||
{
|
||||
path: "/sessions/:id",
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<SessionPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
element: <SessionPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,9 +1,411 @@
|
||||
import { useParams } from "react-router";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import LoaderIcon from "../components/icons/LoaderIcon";
|
||||
import CheckIcon from "../components/icons/CheckIcon";
|
||||
import ClockIcon from "../components/icons/ClockIcon";
|
||||
import WarningIcon from "../components/icons/WarningIcon";
|
||||
import StartIcon from "../components/icons/StartIcon";
|
||||
import Button from "../components/ui/Button";
|
||||
import clsx from "clsx";
|
||||
import { useEffect } from "react";
|
||||
import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
appId: string;
|
||||
userId: string | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
tier: "demo" | "prod" | null;
|
||||
serverId: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
app?: {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
gpuLimitMb: number | null;
|
||||
psVersion: number | null;
|
||||
};
|
||||
server?: {
|
||||
id: string;
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
tier: "demo" | "prod" | null;
|
||||
location: "ru1" | "uae1" | null;
|
||||
} | null;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function SessionPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <div>SessionPage {id}</div>;
|
||||
const {
|
||||
data: sessionData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["session", id],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`sessions/${id}`).json<{
|
||||
session: Session;
|
||||
}>();
|
||||
return response;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
// Автоматически обновляем каждые 2 секунды, если сессия в процессе запуска
|
||||
const data = query.state.data;
|
||||
if (
|
||||
data?.session.status === "starting" ||
|
||||
data?.session.status === "ending"
|
||||
) {
|
||||
return 2000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const session = sessionData?.session;
|
||||
|
||||
// Перенаправление на тестовую страницу при завершении сессии
|
||||
useEffect(() => {
|
||||
if (session?.status === "ended") {
|
||||
const timer = setTimeout(() => {
|
||||
navigate("/test");
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [session?.status, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<div className="size-12 text-[#7B60F3] animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
<p className="text-gray-600 text-m">
|
||||
Загрузка информации о сессии...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="p-8 w-full max-w-2xl bg-white rounded-lg shadow-md">
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="text-red-500 size-6">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-2 text-red-900 title-l">Сессия не найдена</h1>
|
||||
<p className="mb-6 text-gray-600 text-m">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Не удалось загрузить информацию о сессии"}
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8 min-h-screen bg-gray-50">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Header с названием приложения */}
|
||||
<div className="flex gap-4 justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="font-bold text-gray-900 title-l">
|
||||
{session.app?.title || "Приложение"}
|
||||
</h1>
|
||||
<p className="text-gray-500 text-s">
|
||||
ID сессии: {session.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
{/* Pixel Streaming Player - показывается когда сессия запущена */}
|
||||
{session.status === "started" && (
|
||||
<div className="mb-6 aspect-video">
|
||||
<PixelStreamingWrapper
|
||||
initialSettings={{
|
||||
ss: "ws://127.0.0.1:8080",
|
||||
AutoPlayVideo: true,
|
||||
AutoConnect: true,
|
||||
StartVideoMuted: true,
|
||||
HoveringMouse: true,
|
||||
WaitForStreamer: true,
|
||||
}}
|
||||
// onVideoInitialized={() => setIsVideoInitialized(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Основная карточка с информацией */}
|
||||
<div className="grid gap-6 mb-6 md:grid-cols-2">
|
||||
{/* Информация о сессии */}
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<StartIcon />
|
||||
</div>
|
||||
Информация о сессии
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Режим" value={getModeLabel(session.mode)} />
|
||||
{session.tier && (
|
||||
<InfoRow label="Уровень" value={getTierLabel(session.tier)} />
|
||||
)}
|
||||
<InfoRow label="Начало" value={formatDateTime(session.startAt)} />
|
||||
{session.endAt && (
|
||||
<InfoRow
|
||||
label="Завершение"
|
||||
value={formatDateTime(session.endAt)}
|
||||
/>
|
||||
)}
|
||||
{session.appPid && (
|
||||
<InfoRow label="PID приложения" value={session.appPid} />
|
||||
)}
|
||||
{session.cirrusPid && (
|
||||
<InfoRow label="PID Cirrus" value={session.cirrusPid} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о сервере */}
|
||||
{session.server && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Hostname" value={session.server.hostname} />
|
||||
<InfoRow label="IP адрес" value={session.server.localIp} />
|
||||
<InfoRow
|
||||
label="Тип"
|
||||
value={
|
||||
session.server.type === "stream" ? "Стрим" : "Локальный"
|
||||
}
|
||||
/>
|
||||
{session.server.location && (
|
||||
<InfoRow
|
||||
label="Локация"
|
||||
value={getLocationLabel(session.server.location)}
|
||||
/>
|
||||
)}
|
||||
{session.server.tier && (
|
||||
<InfoRow
|
||||
label="Уровень"
|
||||
value={getTierLabel(session.server.tier)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Если сервер еще не назначен */}
|
||||
{!session.server && session.status === "starting" && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="text-yellow-500 animate-spin size-5">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<p className="text-gray-600 text-s">
|
||||
Подбирается подходящий сервер...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о приложении */}
|
||||
{session.app && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm md:col-span-2">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<ClockIcon />
|
||||
</div>
|
||||
Приложение
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<InfoRow label="Название" value={session.app.title} />
|
||||
<InfoRow label="Имя системное" value={session.app.name} />
|
||||
{session.app.gpuLimitMb && (
|
||||
<InfoRow
|
||||
label="Лимит GPU"
|
||||
value={`${session.app.gpuLimitMb} МБ`}
|
||||
/>
|
||||
)}
|
||||
{session.app.psVersion && (
|
||||
<InfoRow label="Версия PS" value={session.app.psVersion} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статусное сообщение */}
|
||||
<StatusMessage status={session.status} />
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button variant="secondary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
{(session.status === "starting" || session.status === "ending") && (
|
||||
<Button variant="primary" onClick={() => refetch()}>
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компоненты помощники
|
||||
function StatusBadge({ status }: { status: Session["status"] }) {
|
||||
const config = {
|
||||
starting: {
|
||||
label: "Запускается",
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
started: {
|
||||
label: "Запущена",
|
||||
color: "bg-green-100 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
animate: false,
|
||||
},
|
||||
ending: {
|
||||
label: "Завершается",
|
||||
color: "bg-orange-100 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
ended: {
|
||||
label: "Завершена",
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
animate: false,
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = config[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-2 items-center px-4 py-2 rounded-xl button-m font-medium",
|
||||
statusConfig.color
|
||||
)}
|
||||
>
|
||||
<div className={clsx("size-4", statusConfig.animate && "animate-spin")}>
|
||||
{statusConfig.icon}
|
||||
</div>
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-gray-500 text-s">{label}:</span>
|
||||
<span className="font-medium text-right text-gray-900 text-s">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusMessage({ status }: { status: Session["status"] }) {
|
||||
const messages = {
|
||||
starting: {
|
||||
text: "Сессия запускается. Пожалуйста, подождите...",
|
||||
color: "bg-yellow-50 border-yellow-200 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
started: {
|
||||
text: "Сессия успешно запущена и готова к работе!",
|
||||
color: "bg-green-50 border-green-200 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
},
|
||||
ending: {
|
||||
text: "Сессия завершается...",
|
||||
color: "bg-orange-50 border-orange-200 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
ended: {
|
||||
text: "Сессия завершена. Через 5 секунд вы будете перенаправлены на главную страницу.",
|
||||
color: "bg-gray-50 border-gray-200 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
const message = messages[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 items-start p-4 mb-6 rounded-xl border",
|
||||
message.color
|
||||
)}
|
||||
>
|
||||
<div className="size-5 flex-shrink-0 mt-0.5">{message.icon}</div>
|
||||
<p className="text-m">{message.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Утилиты форматирования
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getModeLabel(mode: "stream" | "local"): string {
|
||||
return mode === "stream" ? "Стриминг" : "Локальный";
|
||||
}
|
||||
|
||||
function getTierLabel(tier: "demo" | "prod"): string {
|
||||
return tier === "demo" ? "Демо" : "Продакшн";
|
||||
}
|
||||
|
||||
function getLocationLabel(location: "ru1" | "uae1"): string {
|
||||
const labels = {
|
||||
ru1: "Россия (ru1)",
|
||||
uae1: "ОАЭ (uae1)",
|
||||
};
|
||||
return labels[location] || location;
|
||||
}
|
||||
|
||||
export default SessionPage;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
appId: string;
|
||||
userId: string | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
tier: "demo" | "prod";
|
||||
serverId: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
}
|
||||
|
||||
function TestPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStartSession = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api
|
||||
.post("sessions", {
|
||||
json: {
|
||||
appId: "2914d736-b928-461c-b58f-e5d35d8b605d",
|
||||
mode: "stream",
|
||||
tier: "demo",
|
||||
},
|
||||
})
|
||||
.json<{ session: Session }>();
|
||||
|
||||
// Перенаправляем на страницу сессии
|
||||
navigate(`/sessions/${response.session.id}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to start session:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Не удалось запустить приложение"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center p-4 min-h-screen bg-gray-50">
|
||||
<div className="p-8 w-full max-w-md bg-white rounded-lg shadow-md">
|
||||
<h1 className="mb-4 text-2xl font-bold text-gray-900">
|
||||
Тестовая страница
|
||||
</h1>
|
||||
<p className="mb-6 text-gray-600">
|
||||
Запустите демо-приложение без авторизации
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleStartSession}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-3 w-full font-medium text-white bg-blue-600 rounded-lg transition-colors hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Запуск..." : "Запустить приложение"}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 mt-4 text-sm text-red-700 bg-red-100 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestPage;
|
||||
Reference in New Issue
Block a user