diff --git a/apps/control-station/src/components/EnvironmentBackgroundMedia.tsx b/apps/control-station/src/components/EnvironmentBackgroundMedia.tsx new file mode 100644 index 0000000..69b2eb5 --- /dev/null +++ b/apps/control-station/src/components/EnvironmentBackgroundMedia.tsx @@ -0,0 +1,92 @@ +import { useEffect, useMemo, useState } from "react"; + +import type { + EnvironmentBackground, + EnvironmentMediaItem, +} from "../core/environment/environmentSettings"; + +type ReadyEnvironmentMediaItem = EnvironmentMediaItem & { + url: string; + mediaKind: "image" | "video"; +}; + +function isReadyMediaItem( + item: EnvironmentMediaItem, +): item is ReadyEnvironmentMediaItem { + return Boolean(item.url && item.mediaKind); +} + +export function EnvironmentBackgroundMedia({ + background, +}: { + background: EnvironmentBackground; +}) { + const items = useMemo( + () => background.items.filter(isReadyMediaItem), + [background.items], + ); + const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|"); + const [activeIndex, setActiveIndex] = useState(0); + const [failedIds, setFailedIds] = useState>(new Set()); + const playableItems = items.filter((item) => !failedIds.has(item.id)); + const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null; + + useEffect(() => { + setActiveIndex(0); + setFailedIds(new Set()); + }, [playlistIdentity]); + + useEffect(() => { + if ( + !background.enabled + || !activeItem + || activeItem.mediaKind !== "image" + || playableItems.length < 2 + ) { + return; + } + const timer = window.setTimeout(() => { + setActiveIndex((current) => (current + 1) % playableItems.length); + }, background.imageDurationSeconds * 1_000); + return () => window.clearTimeout(timer); + }, [ + activeItem, + background.enabled, + background.imageDurationSeconds, + playableItems.length, + ]); + + if (!background.enabled || !activeItem) return null; + + return ( + + ); +} diff --git a/apps/control-station/src/components/EnvironmentMediaPlaylistEditor.tsx b/apps/control-station/src/components/EnvironmentMediaPlaylistEditor.tsx new file mode 100644 index 0000000..5f6113f --- /dev/null +++ b/apps/control-station/src/components/EnvironmentMediaPlaylistEditor.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Icon, + IconButton, + MediaSourceField, + RangeControl, + SortableList, +} from "@nodedc/ui-react"; + +import { + createEnvironmentMediaItem, + inferEnvironmentMediaKind, + maxEnvironmentMediaItems, + type EnvironmentBackground, + type EnvironmentMediaItem, + type EnvironmentSurfaceId, + type UploadedEnvironmentMedia, +} from "../core/environment/environmentSettings"; + +interface EnvironmentMediaPlaylistEditorProps { + surfaceId: EnvironmentSurfaceId; + background: EnvironmentBackground; + disabled: boolean; + error: string | null; + onChange: (background: EnvironmentBackground) => void; + onBusyChange: (busy: boolean) => void; + onUpload: ( + surfaceId: EnvironmentSurfaceId, + itemId: string, + file: File, + ) => Promise; +} + +const acceptedEnvironmentMedia = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", + "video/mp4", + "video/webm", + "video/quicktime", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".avif", + ".mp4", + ".webm", + ".mov", +].join(","); + +function patchItem( + background: EnvironmentBackground, + itemId: string, + patch: Partial, +): EnvironmentBackground { + return { + ...background, + items: background.items.map((item) => ( + item.id === itemId ? { ...item, ...patch } : item + )), + }; +} + +export function EnvironmentMediaPlaylistEditor({ + surfaceId, + background, + disabled, + error, + onChange, + onBusyChange, + onUpload, +}: EnvironmentMediaPlaylistEditorProps) { + const [uploadingIds, setUploadingIds] = useState>(new Set()); + const [itemErrors, setItemErrors] = useState>({}); + const backgroundRef = useRef(background); + backgroundRef.current = background; + const displayedItems = useMemo( + () => [...background.items].reverse(), + [background.items], + ); + + useEffect(() => { + onBusyChange(uploadingIds.size > 0); + }, [onBusyChange, uploadingIds.size]); + + useEffect(() => () => onBusyChange(false), [onBusyChange]); + + const setItemError = (itemId: string, message?: string) => { + setItemErrors((current) => { + const next = { ...current }; + if (message) next[itemId] = message; + else delete next[itemId]; + return next; + }); + }; + + const uploadFile = async (itemId: string, file?: File) => { + if (!file) return; + setUploadingIds((current) => new Set(current).add(itemId)); + setItemError(itemId); + try { + const uploaded = await onUpload(surfaceId, itemId, file); + onChange(patchItem(backgroundRef.current, itemId, { + source: "file", + url: uploaded.url, + mediaKind: uploaded.mediaKind, + fileName: uploaded.fileName, + })); + } catch (reason) { + setItemError( + itemId, + reason instanceof Error + ? reason.message + : "Не удалось загрузить медиаконтент.", + ); + } finally { + setUploadingIds((current) => { + const next = new Set(current); + next.delete(itemId); + return next; + }); + } + }; + + return ( +
+
+
+ Видео / картинка +

MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.

+
+ = maxEnvironmentMediaItems} + onClick={() => onChange({ + ...background, + items: [...background.items, createEnvironmentMediaItem()], + })} + > + + +
+ + {displayedItems.length ? ( + item.id} + className="environment-media-playlist__items" + onReorder={(items) => onChange({ + ...background, + items: [...items].reverse(), + })} + > + {(item, { handle }) => { + const playbackIndex = background.items.findIndex( + (candidate) => candidate.id === item.id, + ); + return ( +
+ { + if (source === item.source) return; + setItemError(item.id); + onChange(patchItem(background, item.id, { + source, + url: null, + mediaKind: null, + fileName: null, + })); + }} + onUrlChange={(url) => { + setItemError(item.id); + onChange(patchItem(background, item.id, { + source: "url", + url: url || null, + mediaKind: url ? inferEnvironmentMediaKind(url) : null, + fileName: null, + })); + }} + onFileChange={(file) => void uploadFile(item.id, file)} + /> +
+ { + setItemError(item.id); + const items = background.items.filter( + (candidate) => candidate.id !== item.id, + ); + onChange({ + ...background, + enabled: items.length ? background.enabled : false, + items, + }); + }} + > + + + {handle} +
+
+ ); + }} +
+ ) : ( + <> +

+ Добавьте первый файл или прямую ссылку на медиаконтент. +

+ {error ? ( +

+ {error} +

+ ) : null} + + )} + +
+ `${value} с`} + onChange={(imageDurationSeconds) => onChange({ + ...background, + imageDurationSeconds, + })} + /> + + Новые элементы появляются сверху. Воспроизведение начинается снизу; + перетаскивание меняет порядок. + +
+
+ ); +} diff --git a/apps/control-station/src/components/EnvironmentSettingsWindow.tsx b/apps/control-station/src/components/EnvironmentSettingsWindow.tsx index f77ceba..6e700fc 100644 --- a/apps/control-station/src/components/EnvironmentSettingsWindow.tsx +++ b/apps/control-station/src/components/EnvironmentSettingsWindow.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import { Button, FeatureSettingsWindow, - MediaSourceField, Select, SettingsCard, Switch, @@ -14,12 +13,12 @@ import { import { cloneEnvironmentSettings, type EnvironmentBackground, - type EnvironmentMediaKind, type EnvironmentPage, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia, } from "../core/environment/environmentSettings"; +import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor"; import { roots, workspaces, @@ -35,14 +34,11 @@ interface EnvironmentSettingsWindowProps { onSave: (settings: EnvironmentSettings) => Promise; onUpload: ( surfaceId: EnvironmentSurfaceId, + itemId: string, file: File, ) => Promise; } -function inferMediaKind(url: string): EnvironmentMediaKind { - return /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url) ? "video" : "image"; -} - function patchPage( draft: EnvironmentSettings, surfaceId: EnvironmentSurfaceId, @@ -108,8 +104,6 @@ export function EnvironmentSettingsWindow({ description: `Стартовая страница раздела «${draft.pages[root.id].title}»`, })), ], [draft.pages]); - const previewKind = selectedBackground.mediaKind - ?? (selectedBackground.url ? inferMediaKind(selectedBackground.url) : null); const quickActionWorkspaces = useMemo( () => surfaceId === "home" ? workspaces.filter((workspace) => !workspace.internalOnly) @@ -138,28 +132,6 @@ export function EnvironmentSettingsWindow({ setDraft((current) => patchPage(current, surfaceId, patch)); }; - const uploadFile = async (file?: File) => { - if (!file) return; - setUploading(true); - setLocalError(null); - try { - const uploaded = await onUpload(surfaceId, file); - setDraft((current) => patchBackground(current, surfaceId, { - enabled: true, - source: "file", - url: uploaded.url, - mediaKind: uploaded.mediaKind, - fileName: uploaded.fileName, - })); - } catch (reason) { - setLocalError(reason instanceof Error - ? reason.message - : "Не удалось загрузить фон окружения."); - } finally { - setUploading(false); - } - }; - const save = async () => { const invalid = Object.entries(draft.pages).find(([, page]) => ( !page.headerLabel.trim() @@ -178,6 +150,26 @@ export function EnvironmentSettingsWindow({ setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности."); return; } + const invalidMedia = Object.entries(draft.pages).find(([, page]) => ( + (page.background.enabled && !page.background.items.length) + || page.background.items.some((item) => { + if (!item.url || !item.mediaKind) return true; + if (item.source !== "url") return false; + try { + const parsed = new URL(item.url); + return !["http:", "https:"].includes(parsed.protocol); + } catch { + return true; + } + }) + )); + if (invalidMedia) { + setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId); + setLocalError( + "Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.", + ); + return; + } setLocalError(null); try { await onSave({ @@ -253,8 +245,8 @@ export function EnvironmentSettingsWindow({ checked={selectedBackground.enabled} label="Показывать фон" onChange={(enabled) => { - if (enabled && !selectedBackground.url) { - setLocalError("Сначала загрузите файл или укажите URL."); + if (enabled && !selectedBackground.items.length) { + setLocalError("Сначала добавьте медиаконтент."); return; } setLocalError(null); @@ -345,29 +337,18 @@ export function EnvironmentSettingsWindow({ - setDraft((current) => - patchBackground(current, surfaceId, { source }))} - onUrlChange={(url) => setDraft((current) => - patchBackground(current, surfaceId, { - source: "url", - url: url || null, - mediaKind: url ? inferMediaKind(url) : null, - fileName: null, - }))} - onFileChange={uploadFile} + onBusyChange={setUploading} + onChange={(background) => { + setLocalError(null); + setDraft((current) => + patchBackground(current, surfaceId, background)); + }} + onUpload={onUpload} /> diff --git a/apps/control-station/src/components/LandingStage.tsx b/apps/control-station/src/components/LandingStage.tsx index ac56a3f..0bd54e7 100644 --- a/apps/control-station/src/components/LandingStage.tsx +++ b/apps/control-station/src/components/LandingStage.tsx @@ -4,6 +4,7 @@ import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts"; import type { EnvironmentPage } from "../core/environment/environmentSettings"; import type { RootDefinition, WorkspaceDefinition } from "../productModel"; import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation"; +import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia"; export interface LandingStageProps { root: RootDefinition | null; @@ -25,28 +26,16 @@ export function LandingStage({ onOpenWorkspace, }: LandingStageProps) { const { background } = page; + const hasMedia = background.enabled && background.items.some( + (item) => item.url && item.mediaKind, + ); return (
- {background.enabled && background.url ? ( - - ) : null} +