feat(ui): add environment media playlists

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 14:20:19 +03:00
parent c455301764
commit 8b1f109b09
11 changed files with 1076 additions and 138 deletions
@@ -4,14 +4,20 @@ export type EnvironmentSurfaceId = "home" | RootId;
export type EnvironmentMediaKind = "image" | "video";
export type EnvironmentMediaSource = "file" | "url";
export interface EnvironmentBackground {
enabled: boolean;
export interface EnvironmentMediaItem {
id: string;
source: EnvironmentMediaSource;
url: string | null;
mediaKind: EnvironmentMediaKind | null;
fileName: string | null;
}
export interface EnvironmentBackground {
enabled: boolean;
imageDurationSeconds: number;
items: EnvironmentMediaItem[];
}
export interface EnvironmentPage {
headerLabel: string;
eyebrow: string;
@@ -29,6 +35,7 @@ export interface EnvironmentSettings {
export interface UploadedEnvironmentMedia {
surfaceId: EnvironmentSurfaceId;
itemId: string;
url: string;
fileName: string;
mediaKind: EnvironmentMediaKind;
@@ -47,9 +54,20 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
"polygon",
];
export const defaultEnvironmentImageDurationSeconds = 10;
export const maxEnvironmentMediaItems = 24;
function emptyBackground(): EnvironmentBackground {
return {
enabled: false,
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
items: [],
};
}
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
return {
id: `media-${crypto.randomUUID()}`,
source: "file",
url: null,
mediaKind: null,
@@ -57,6 +75,10 @@ function emptyBackground(): EnvironmentBackground {
};
}
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
}
const defaultQuickActions: Record<
EnvironmentSurfaceId,
readonly [string | null, string | null]
@@ -110,26 +132,57 @@ function requireString(
return value;
}
function decodeMediaItem(value: unknown, path: string): EnvironmentMediaItem {
const record = requireRecord(value, path);
if (
typeof record.id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.id)
) {
throw new Error(`${path}.id не поддерживается.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error(`${path}.media_kind не поддерживается.`);
}
return {
id: record.id,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`),
mediaKind: record.media_kind as EnvironmentMediaKind,
fileName: requireString(record.file_name, `${path}.file_name`, true),
};
}
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
const record = requireRecord(value, path);
if (typeof record.enabled !== "boolean") {
throw new Error(`${path}.enabled должен быть boolean.`);
}
if (!["file", "url"].includes(String(record.source))) {
throw new Error(`${path}.source не поддерживается.`);
if (
typeof record.image_duration_seconds !== "number"
|| !Number.isSafeInteger(record.image_duration_seconds)
|| record.image_duration_seconds < 1
|| record.image_duration_seconds > 300
) {
throw new Error(`${path}.image_duration_seconds некорректен.`);
}
if (
record.media_kind !== null
&& !["image", "video"].includes(String(record.media_kind))
!Array.isArray(record.items)
|| record.items.length > maxEnvironmentMediaItems
) {
throw new Error(`${path}.media_kind не поддерживается.`);
throw new Error(`${path}.items некорректен.`);
}
const items = record.items.map((item, index) =>
decodeMediaItem(item, `${path}.items[${index}]`));
if (new Set(items.map((item) => item.id)).size !== items.length) {
throw new Error(`${path}.items содержит повторяющиеся id.`);
}
return {
enabled: record.enabled,
source: record.source as EnvironmentMediaSource,
url: requireString(record.url, `${path}.url`, true),
mediaKind: record.media_kind as EnvironmentMediaKind | null,
fileName: requireString(record.file_name, `${path}.file_name`, true),
imageDurationSeconds: record.image_duration_seconds,
items,
};
}
@@ -156,7 +209,7 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
const record = requireRecord(value, "environment");
if (record.schema_version !== "missioncore.operator-environment/v2") {
if (record.schema_version !== "missioncore.operator-environment/v3") {
throw new Error("Версия настроек окружения не поддерживается.");
}
if (
@@ -190,10 +243,14 @@ export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknow
secondary_workspace_id: page.secondaryWorkspaceId,
background: {
enabled: page.background.enabled,
source: page.background.source,
url: page.background.url,
media_kind: page.background.mediaKind,
file_name: page.background.fileName,
image_duration_seconds: page.background.imageDurationSeconds,
items: page.background.items.map((item) => ({
id: item.id,
source: item.source,
url: item.url,
media_kind: item.mediaKind,
file_name: item.fileName,
})),
},
}];
})),
@@ -204,12 +261,18 @@ export function decodeUploadedEnvironmentMedia(
value: unknown,
): UploadedEnvironmentMedia {
const record = requireRecord(value, "environment media");
if (record.schema_version !== "missioncore.operator-environment-media/v1") {
if (record.schema_version !== "missioncore.operator-environment-media/v2") {
throw new Error("Версия загруженного media не поддерживается.");
}
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
throw new Error("Экран загруженного media не поддерживается.");
}
if (
typeof record.item_id !== "string"
|| !/^media-[a-z0-9-]{1,58}$/.test(record.item_id)
) {
throw new Error("Идентификатор загруженного media не поддерживается.");
}
if (!["image", "video"].includes(String(record.media_kind))) {
throw new Error("Тип загруженного media не поддерживается.");
}
@@ -222,6 +285,7 @@ export function decodeUploadedEnvironmentMedia(
}
return {
surfaceId: record.surface_id as EnvironmentSurfaceId,
itemId: record.item_id,
url: requireString(record.url, "environment media.url")!,
fileName: requireString(record.file_name, "environment media.file_name")!,
mediaKind: record.media_kind as EnvironmentMediaKind,
@@ -240,7 +304,12 @@ export function cloneEnvironmentSettings(
surfaceId,
{
...settings.pages[surfaceId],
background: { ...settings.pages[surfaceId].background },
background: {
...settings.pages[surfaceId].background,
items: settings.pages[surfaceId].background.items.map((item) => ({
...item,
})),
},
},
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
};
@@ -17,6 +17,7 @@ interface EnvironmentSettingsController {
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
upload: (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => Promise<UploadedEnvironmentMedia>;
}
@@ -101,17 +102,21 @@ export function useEnvironmentSettings(): EnvironmentSettingsController {
const upload = useCallback(async (
surfaceId: EnvironmentSurfaceId,
itemId: string,
file: File,
) => {
const response = await fetch(`/api/v1/environment/media/${surfaceId}`, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
const response = await fetch(
`/api/v1/environment/media/${surfaceId}/${itemId}`,
{
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": file.type || "application/octet-stream",
"X-NODEDC-File-Name": encodeURIComponent(file.name),
},
body: file,
},
body: file,
});
);
if (!response.ok) {
throw new Error(await responseError(
response,