From 91f477a0f2aae410127e175494293a0467a2d7d6 Mon Sep 17 00:00:00 2001 From: Saelmala Date: Fri, 23 Aug 2024 09:24:02 +0200 Subject: [PATCH 01/82] feat: support media and html sources in prod and web sockets --- .env.sample | 4 + package-lock.json | 32 +- package.json | 6 +- .../pipelines/multiviews/multiviews.ts | 7 +- src/api/ateliereLive/websocket.ts | 40 +++ src/api/manager/productions.ts | 27 +- src/api/manager/sources.ts | 54 ++-- src/api/manager/workflow.ts | 85 +++++- src/app/html_input/page.tsx | 10 + src/app/production/[id]/page.tsx | 276 ++++++++++-------- src/components/addInput/AddInput.tsx | 30 ++ src/components/addSource/AddSource.tsx | 24 -- src/components/dragElement/DragItem.tsx | 48 +-- src/components/filter/FilterDropdown.tsx | 5 +- src/components/filter/FilterOptions.tsx | 8 + src/components/filter/SortSelect.tsx | 21 -- src/components/modal/AddSourceModal.tsx | 1 - src/components/select/Select.tsx | 30 ++ src/components/sourceCard/SourceCard.tsx | 123 +++++--- src/components/sourceCard/SourceThumbnail.tsx | 58 ++-- src/components/sourceCards/SourceCards.tsx | 111 ++++--- .../sourceListItem/SourceListItem.tsx | 4 +- .../startProduction/StartProductionButton.tsx | 5 + src/hooks/items/addSetupItem.ts | 3 +- src/hooks/pipelines.ts | 1 + src/hooks/productions.ts | 7 +- src/hooks/sources/useAddSource.tsx | 38 +++ src/hooks/sources/useSources.tsx | 6 +- src/hooks/useDragableItems.ts | 89 +++--- src/hooks/useGetFirstEmptySlot.ts | 37 +++ src/i18n/locales/en.ts | 10 +- src/i18n/locales/sv.ts | 10 +- src/interfaces/Source.ts | 6 +- src/middleware.ts | 2 +- 34 files changed, 826 insertions(+), 392 deletions(-) create mode 100644 src/api/ateliereLive/websocket.ts create mode 100644 src/app/html_input/page.tsx create mode 100644 src/components/addInput/AddInput.tsx delete mode 100644 src/components/addSource/AddSource.tsx delete mode 100644 src/components/filter/SortSelect.tsx create mode 100644 src/components/select/Select.tsx create mode 100644 src/hooks/sources/useAddSource.tsx create mode 100644 src/hooks/useGetFirstEmptySlot.ts diff --git a/.env.sample b/.env.sample index 8c3990fc..3ff9058c 100644 --- a/.env.sample +++ b/.env.sample @@ -4,6 +4,7 @@ MONGODB_URI=${MONGODB_URI:-mongodb://api:password@localhost:27017/live-gui} # Ateliere Live System Controlleer LIVE_URL=${LIVE_URL:-https://localhost:8080} LIVE_CREDENTIALS=${LIVE_CREDENTIALS:-admin:admin} +CONTROL_PANEL_WS==${} # This ENV variable disables SSL Verification, use if the above LIVE_URL doesn't have a proper certificate NODE_TLS_REJECT_UNAUTHORIZED=${NODE_TLS_REJECT_UNAUTHORIZED:-1} @@ -14,3 +15,6 @@ BCRYPT_SALT_ROUNDS=${BCRYPT_SALT_ROUNDS:-10} # i18n UI_LANG=${UI_LANG:-en} + +# Mediaplayer - path on the system controller +MEDIAPLAYER_PLACEHOLDER=/media/media_placeholder.mp4 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 73c7afff..26159b5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@sinclair/typebox": "^0.25.24", "@tabler/icons": "^2.22.0", "@tabler/icons-react": "^2.20.0", + "@types/ws": "^8.5.12", "bcrypt": "^5.1.0", "cron": "^2.3.1", "date-fns": "^2.30.0", @@ -35,7 +36,8 @@ "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "tailwind-merge": "^1.13.2", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.18.0" }, "devDependencies": { "@commitlint/cli": "^17.4.2", @@ -2512,6 +2514,14 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -11233,6 +11243,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3d341165..7b3597c1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "pretty:format": "prettier --write .", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "next lint", - "dev": "./update_gui_version.sh && next dev", + "dev": "next dev", "build": "next build", "start": "next start", "version:rc": "npm version prerelease --preid=rc", @@ -32,6 +32,7 @@ "@sinclair/typebox": "^0.25.24", "@tabler/icons": "^2.22.0", "@tabler/icons-react": "^2.20.0", + "@types/ws": "^8.5.12", "bcrypt": "^5.1.0", "cron": "^2.3.1", "date-fns": "^2.30.0", @@ -48,7 +49,8 @@ "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "tailwind-merge": "^1.13.2", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.18.0" }, "devDependencies": { "@commitlint/cli": "^17.4.2", diff --git a/src/api/ateliereLive/pipelines/multiviews/multiviews.ts b/src/api/ateliereLive/pipelines/multiviews/multiviews.ts index 5f24a869..ac76f6c3 100644 --- a/src/api/ateliereLive/pipelines/multiviews/multiviews.ts +++ b/src/api/ateliereLive/pipelines/multiviews/multiviews.ts @@ -64,13 +64,12 @@ export async function createMultiviewForPipeline( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion productionSettings.pipelines[multiviewIndex].pipeline_id!; const sources = await getSourcesByIds( - sourceRefs.map((ref) => ref._id.toString()) + sourceRefs.map((ref) => (ref._id ? ref._id.toString() : '')) ); const sourceRefsWithLabels = sourceRefs.map((ref) => { + const refId = ref._id ? ref._id.toString() : ''; if (!ref.label) { - const source = sources.find( - (source) => source._id.toString() === ref._id.toString() - ); + const source = sources.find((source) => source._id.toString() === refId); ref.label = source?.name || ''; } return ref; diff --git a/src/api/ateliereLive/websocket.ts b/src/api/ateliereLive/websocket.ts new file mode 100644 index 00000000..0cbe35d8 --- /dev/null +++ b/src/api/ateliereLive/websocket.ts @@ -0,0 +1,40 @@ +import WebSocket from 'ws'; + +function createWebSocket(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://${process.env.CONTROL_PANEL_WS}`); + ws.on('error', reject); + ws.on('open', () => { + // const send = ws.send.bind(ws); + // ws.send = (message) => { + // console.debug(`[websocket] sending message: ${message}`); + // send(message); + // }; + resolve(ws); + }); + }); +} + +export async function createControlPanelWebSocket() { + const ws = await createWebSocket(); + return { + createHtml: (input: number) => { + ws.send(`html create ${input} 1920 1080`); + }, + createMediaplayer: (input: number) => { + ws.send(`media create ${input} ${process.env.MEDIAPLAYER_PLACEHOLDER}`); + }, + closeHtml: (input: number) => { + ws.send(`html close ${input}`); + ws.send('html reset'); + }, + closeMediaplayer: (input: number) => { + ws.send(`media close ${input}`); + ws.send('media reset'); + }, + close: () => + setTimeout(() => { + ws.close(); + }, 1000) + }; +} diff --git a/src/api/manager/productions.ts b/src/api/manager/productions.ts index e68524ad..e3647767 100644 --- a/src/api/manager/productions.ts +++ b/src/api/manager/productions.ts @@ -28,14 +28,29 @@ export async function setProductionsIsActiveFalse(): Promise< export async function putProduction( id: string, production: Production -): Promise { +): Promise { const db = await getDatabase(); + const newSourceId = new ObjectId().toString(); + + const sources = production.sources + ? production.sources.flatMap((singleSource) => { + return singleSource._id + ? singleSource + : { + _id: newSourceId, + type: singleSource.type, + label: singleSource.label, + input_slot: singleSource.input_slot + }; + }) + : []; + await db.collection('productions').findOneAndReplace( { _id: new ObjectId(id) }, { name: production.name, isActive: production.isActive, - sources: production.sources, + sources: sources, production_settings: production.production_settings } ); @@ -43,6 +58,14 @@ export async function putProduction( if (!production.isActive) { deleteMonitoring(db, id); } + + return { + _id: new ObjectId(id).toString(), + name: production.name, + isActive: production.isActive, + sources: sources, + production_settings: production.production_settings + }; } export async function postProduction(data: Production): Promise { diff --git a/src/api/manager/sources.ts b/src/api/manager/sources.ts index 8bb83e80..4e77f393 100644 --- a/src/api/manager/sources.ts +++ b/src/api/manager/sources.ts @@ -1,6 +1,6 @@ import inventory from './mocks/inventory.json'; import { Source } from '../../interfaces/Source'; -import { ObjectId } from 'mongodb'; +import { ObjectId, OptionalId, WithId } from 'mongodb'; import { getDatabase } from '../mongoClient/dbClient'; export function getMockedSources() { @@ -9,37 +9,45 @@ export function getMockedSources() { export async function postSource(data: Source): Promise { const db = await getDatabase(); - return (await db.collection('inventory').insertOne(data)) - .insertedId as ObjectId; + const insertData: OptionalId> & { _id?: ObjectId } = { + ...data, + _id: typeof data._id === 'string' ? new ObjectId(data._id) : data._id + }; + const result = await db.collection('inventory').insertOne(insertData); + return result.insertedId as ObjectId; } export async function getSources() { const db = await getDatabase(); return await db.collection('inventory').find().toArray(); } - -export async function getSourcesByIds(_ids: string[]) { +export async function getSourcesByIds( + _ids: string[] +): Promise[]> { const db = await getDatabase().catch(() => { - throw "Can't connect to Database"; - }); - const objectIds = _ids.map((id: string) => { - return new ObjectId(id); + throw new Error("Can't connect to Database"); }); + const objectIds = _ids.map((id: string) => new ObjectId(id)); - return ( - await db - .collection('inventory') - .find({ - _id: { - $in: objectIds - } - }) - .toArray() - ).sort( - (a, b) => - _ids.findIndex((id) => a._id.equals(id)) - - _ids.findIndex((id) => b._id.equals(id)) - ); + const sources = await db + .collection('inventory') + .find({ + _id: { + $in: objectIds + } + }) + .toArray(); + + return sources.sort((a, b) => { + const findIndex = (id: ObjectId | string) => + _ids.findIndex((originalId) => + id instanceof ObjectId + ? id.equals(new ObjectId(originalId)) + : id === originalId + ); + + return findIndex(a._id) - findIndex(b._id); + }); } export async function updateSource(source: any) { diff --git a/src/api/manager/workflow.ts b/src/api/manager/workflow.ts index 7538be78..7b9e392f 100644 --- a/src/api/manager/workflow.ts +++ b/src/api/manager/workflow.ts @@ -1,3 +1,4 @@ +import { SourceReference, SourceWithId } from './../../interfaces/Source'; import { Production, ProductionSettings, @@ -35,7 +36,7 @@ import { ResourcesSenderNetworkEndpoint } from '../../../types/ateliere-live'; import { getSourcesByIds } from './sources'; -import { SourceWithId, SourceToPipelineStream } from '../../interfaces/Source'; +import { SourceToPipelineStream } from '../../interfaces/Source'; import { getAvailablePortsForIngest, getCurrentlyUsedPorts, @@ -49,6 +50,8 @@ import { Result } from '../../interfaces/result'; import { Monitoring } from '../../interfaces/monitoring'; import { getDatabase } from '../mongoClient/dbClient'; import { updatedMonitoringForProduction } from './job/syncMonitoring'; +import { createControlPanelWebSocket } from '../ateliereLive/websocket'; +import { ObjectId } from 'mongodb'; const isUsed = (pipeline: ResourcesPipelineResponse) => { const hasStreams = pipeline.streams.length > 0; @@ -68,15 +71,18 @@ const isUsed = (pipeline: ResourcesPipelineResponse) => { }; async function connectIngestSources( + productionSources: SourceReference[], productionSettings: ProductionSettings, sources: SourceWithId[], usedPorts: Set ) { - let input_slot = 0; const sourceToPipelineStreams: SourceToPipelineStream[] = []; + let input_slot = 0; for (const source of sources) { - input_slot = input_slot + 1; + input_slot = + productionSources.find((s) => s._id === source._id.toString()) + ?.input_slot || input_slot + 1; const ingestUuid = await getUuidFromIngestName( source.ingest_name, false @@ -89,7 +95,8 @@ async function connectIngestSources( source.ingest_source_name, false ); - const audioSettings = await getAudioMapping(source._id); + + const audioSettings = await getAudioMapping(new ObjectId(source._id)); const newAudioMapping = audioSettings?.audio_stream?.audio_mapping; const audioMapping = newAudioMapping?.length ? newAudioMapping : [[0, 1]]; @@ -108,6 +115,7 @@ async function connectIngestSources( Log().info( `Allocated port ${availablePort} on '${source.ingest_name}' for ${source.ingest_source_name}` ); + const stream: PipelineStreamSettings = { pipeline_id: pipeline.pipeline_id!, alignment_ms: pipeline.alignment_ms, @@ -138,9 +146,10 @@ async function connectIngestSources( } ] }; + try { Log().info( - `Connecting '${source.ingest_name}/${ingestUuid}}:${source.ingest_source_name}' to '${pipeline.pipeline_name}/${pipeline.pipeline_id}'` + `Connecting '${source.ingest_name}/${ingestUuid}:${source.ingest_source_name}' to '${pipeline.pipeline_name}/${pipeline.pipeline_id}'` ); Log().debug(stream); const result = await connectIngestToPipeline(stream).catch((error) => { @@ -150,6 +159,7 @@ async function connectIngestSources( ); throw `Source '${source.ingest_name}/${ingestUuid}:${source.ingest_source_name}' failed to connect to '${pipeline.pipeline_name}/${pipeline.pipeline_id}': ${error.message}`; }); + usedPorts.add(availablePort); sourceToPipelineStreams.push({ source_id: source._id.toString(), @@ -308,6 +318,24 @@ export async function stopProduction( (p) => p.pipeline_id ); + const controlPanelWS = await createControlPanelWebSocket(); + const htmlSources = production.sources.filter( + (source) => source.type === 'html' + ); + const mediaPlayerSources = production.sources.filter( + (source) => source.type === 'mediaplayer' + ); + + for (const source of htmlSources) { + controlPanelWS.closeHtml(source.input_slot); + } + + for (const source of mediaPlayerSources) { + controlPanelWS.closeMediaplayer(source.input_slot); + } + + controlPanelWS.close(); + for (const source of production.sources) { for (const stream_uuid of source.stream_uuids || []) { await deleteStreamByUuid(stream_uuid).catch((error) => { @@ -355,6 +383,7 @@ export async function stopProduction( }; } } + try { await removePipelineStreams(id).catch((error) => { Log().error( @@ -409,7 +438,13 @@ export async function stopProduction( } } Log().info(`Pipeline '${id}' stopped`); + + const pipelines = await getPipelines(); + const pipelineFeedbackStreams = pipelines.find( + (p) => p.uuid === id + )?.feedback_streams; } + if ( !disconnectConnectionsStatus.ok || !removePipelineStreamsStatus.ok || @@ -450,9 +485,15 @@ export async function startProduction( try { // Get sources from the DB const sources = await getSourcesByIds( - production.sources.map((source) => { - return source._id.toString(); - }) + production.sources + .filter( + (source) => + (source._id !== undefined && source.type !== 'html') || + source.type !== 'mediaplayer' + ) + .map((source) => { + return source._id!.toString(); + }) ).catch((error) => { if (error === "Can't connect to Database") { throw "Can't connect to Database"; @@ -537,8 +578,8 @@ export async function startProduction( return pipeline.uuid; }) ); - streams = await connectIngestSources( + production.sources, production_settings, sources, usedPorts @@ -611,6 +652,24 @@ export async function startProduction( }; } // Try to connect control panels and pipeline-to-pipeline connections end + const controlPanelWS = await createControlPanelWebSocket(); + const htmlSources = production.sources.filter( + (source) => source.type === 'html' + ); + const mediaPlayerSources = production.sources.filter( + (source) => source.type === 'mediaplayer' + ); + + for (const source of htmlSources) { + controlPanelWS.createHtml(source.input_slot); + } + + for (const source of mediaPlayerSources) { + controlPanelWS.createMediaplayer(source.input_slot); + } + + controlPanelWS.close(); + // Try to setup pipeline outputs start try { for (const pipeline of production_settings.pipelines) { @@ -648,7 +707,6 @@ export async function startProduction( error: e }; } // Try to setup pipeline outputs end - // Try to setup multiviews start try { if (!production.production_settings.pipelines[0].multiviews) { @@ -720,12 +778,13 @@ export async function startProduction( ...production, sources: production.sources.map((source) => { const streamsForSource = streams?.filter( - (stream) => stream.source_id === source._id.toString() + (stream) => stream.source_id === source._id?.toString() ); return { ...source, - stream_uuids: streamsForSource?.map((s) => s.stream_uuid), - input_slot: streamsForSource[0].input_slot + stream_uuids: + streamsForSource?.map((s) => s.stream_uuid) || undefined, + input_slot: source.input_slot }; }), isActive: true diff --git a/src/app/html_input/page.tsx b/src/app/html_input/page.tsx new file mode 100644 index 00000000..81cfaa52 --- /dev/null +++ b/src/app/html_input/page.tsx @@ -0,0 +1,10 @@ +import { PageProps } from '../../../.next/types/app/html_input/page'; + +export default function HtmlInput({ searchParams: { input } }: PageProps) { + return ( +
+

HTML INPUT

+

{input}

+
+ ); +} diff --git a/src/app/production/[id]/page.tsx b/src/app/production/[id]/page.tsx index be3112c7..4fc3cd06 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -1,16 +1,18 @@ 'use client'; + import React, { useEffect, useState, KeyboardEvent } from 'react'; import { PageProps } from '../../../../.next/types/app/production/[id]/page'; import SourceListItem from '../../../components/sourceListItem/SourceListItem'; import FilterOptions from '../../../components/filter/FilterOptions'; -import { AddSource } from '../../../components/addSource/AddSource'; +import { AddInput } from '../../../components/addInput/AddInput'; import { IconX } from '@tabler/icons-react'; import { useSources } from '../../../hooks/sources/useSources'; import { AddSourceStatus, DeleteSourceStatus, SourceReference, - SourceWithId + SourceWithId, + Type } from '../../../interfaces/Source'; import { useGetProduction, usePutProduction } from '../../../hooks/productions'; import { Production } from '../../../interfaces/production'; @@ -40,8 +42,10 @@ import { RemoveSourceModal } from '../../../components/modal/RemoveSourceModal'; import { useDeleteStream, useCreateStream } from '../../../hooks/streams'; import { MonitoringButton } from '../../../components/button/MonitoringButton'; import { useGetMultiviewPreset } from '../../../hooks/multiviewPreset'; -import { ISource } from '../../../hooks/useDragableItems'; import { useMultiviews } from '../../../hooks/multiviews'; +import { useAddSource } from '../../../hooks/sources/useAddSource'; +import { useGetFirstEmptySlot } from '../../../hooks/useGetFirstEmptySlot'; +import { Select } from '../../../components/select/Select'; export default function ProductionConfiguration({ params }: PageProps) { const t = useTranslate(); @@ -51,6 +55,9 @@ export default function ProductionConfiguration({ params }: PageProps) { const [filteredSources, setFilteredSources] = useState( new Map() ); + const [selectedValue, setSelectedValue] = useState( + t('production.add_other_source_type') + ); const [addSourceModal, setAddSourceModal] = useState(false); const [removeSourceModal, setRemoveSourceModal] = useState(false); const [selectedSource, setSelectedSource] = useState< @@ -88,11 +95,36 @@ export default function ProductionConfiguration({ params }: PageProps) { const [deleteSourceStatus, setDeleteSourceStatus] = useState(); + // Create source + const [firstEmptySlot] = useGetFirstEmptySlot(); + const [addSource] = useAddSource(); + + const isAddButtonDisabled = + selectedValue !== 'HTML' && selectedValue !== 'Media Player'; + useEffect(() => { refreshPipelines(); refreshControlPanels(); }, [productionSetup?.isActive]); + const addSourceToProduction = (type: Type) => { + const input: SourceReference = { + type: type, + label: type === 'html' ? 'HTML Input' : 'Media Player Source', + input_slot: firstEmptySlot(productionSetup) + }; + + if (!productionSetup) return; + addSource(input, productionSetup).then((updatedSetup) => { + if (!updatedSetup) return; + setProductionSetup(updatedSetup); + refreshProduction(); + setAddSourceModal(false); + setSelectedSource(undefined); + }); + setAddSourceStatus(undefined); + }; + const setSelectedControlPanel = (controlPanel: string[]) => { setProductionSetup((prevState) => { if (!prevState) return; @@ -219,6 +251,12 @@ export default function ProductionConfiguration({ params }: PageProps) { setFilteredSources(sources); }, [sources]); + useEffect(() => { + if (selectedValue === t('production.source')) { + setInventoryVisible(true); + } + }, [selectedValue]); + const updatePreset = (preset: Preset) => { if (!productionSetup?._id) return; putProduction(productionSetup?._id.toString(), { @@ -362,6 +400,8 @@ export default function ProductionConfiguration({ params }: PageProps) { ); } + + // Adding source to a production, both in setup-mode and in live-mode function getSourcesToDisplay( filteredSources: Map ): React.ReactNode[] { @@ -376,22 +416,18 @@ export default function ProductionConfiguration({ params }: PageProps) { setSelectedSource(source); setAddSourceModal(true); } else if (productionSetup) { - const updatedSetup = addSetupItem( - { - _id: source._id.toString(), - label: source.ingest_source_name, - input_slot: getFirstEmptySlot() - }, - productionSetup - ); - if (!updatedSetup) return; - setProductionSetup(updatedSetup); - putProduction(updatedSetup._id.toString(), updatedSetup).then( - () => { - setAddSourceModal(false); - setSelectedSource(undefined); - } - ); + const input: SourceReference = { + _id: source._id.toString(), + type: 'ingest_source', + label: source.ingest_source_name, + input_slot: firstEmptySlot(productionSetup) + }; + addSource(input, productionSetup).then((updatedSetup) => { + if (!updatedSetup) return; + setProductionSetup(updatedSetup); + setAddSourceModal(false); + setSelectedSource(undefined); + }); } }} /> @@ -399,28 +435,6 @@ export default function ProductionConfiguration({ params }: PageProps) { }); } - const getFirstEmptySlot = () => { - if (!productionSetup) throw 'no_production'; - let firstEmptySlot = productionSetup.sources.length + 1; - if (productionSetup.sources.length === 0) { - return firstEmptySlot; - } - for ( - let i = 0; - i < - productionSetup.sources[productionSetup.sources.length - 1].input_slot; - i++ - ) { - if ( - !productionSetup.sources.some((source) => source.input_slot === i + 1) - ) { - firstEmptySlot = i + 1; - break; - } - } - return firstEmptySlot; - }; - const handleAddSource = async () => { setAddSourceStatus(undefined); if ( @@ -435,11 +449,10 @@ export default function ProductionConfiguration({ params }: PageProps) { ) : false) ) { - const firstEmptySlot = getFirstEmptySlot(); const result = await createStream( selectedSource, productionSetup, - firstEmptySlot ? firstEmptySlot : productionSetup.sources.length + 1 + firstEmptySlot(productionSetup) ); if (!result.ok) { if (!result.value) { @@ -456,11 +469,12 @@ export default function ProductionConfiguration({ params }: PageProps) { } if (result.ok) { if (result.value.success) { - const sourceToAdd = { + const sourceToAdd: SourceReference = { _id: result.value.streams[0].source_id, + type: 'ingest_source', label: selectedSource.name, stream_uuids: result.value.streams.map((r) => r.stream_uuid), - input_slot: getFirstEmptySlot() + input_slot: firstEmptySlot(productionSetup) }; const updatedSetup = addSetupItem(sourceToAdd, productionSetup); if (!updatedSetup) return; @@ -479,12 +493,7 @@ export default function ProductionConfiguration({ params }: PageProps) { }; const handleRemoveSource = async () => { - if ( - productionSetup && - productionSetup.isActive && - selectedSourceRef && - selectedSourceRef.stream_uuids - ) { + if (productionSetup && productionSetup.isActive && selectedSourceRef) { const multiviews = productionSetup.production_settings.pipelines[0].multiviews; @@ -496,9 +505,60 @@ export default function ProductionConfiguration({ params }: PageProps) { ) ); - if (!viewToUpdate) { - if (!productionSetup.production_settings.pipelines[0].pipeline_id) + if (selectedSourceRef.stream_uuids) { + if (!viewToUpdate) { + if (!productionSetup.production_settings.pipelines[0].pipeline_id) + return; + + const result = await deleteStream( + selectedSourceRef.stream_uuids, + productionSetup, + selectedSourceRef.input_slot + ); + + if (!result.ok) { + if (!result.value) { + setDeleteSourceStatus({ + success: false, + steps: [{ step: 'unexpected', success: false }] + }); + } else { + setDeleteSourceStatus({ success: false, steps: result.value }); + const didDeleteStream = result.value.some( + (step) => step.step === 'delete_stream' && step.success + ); + if (didDeleteStream) { + const updatedSetup = removeSetupItem( + selectedSourceRef, + productionSetup + ); + if (!updatedSetup) return; + setProductionSetup(updatedSetup); + putProduction(updatedSetup._id.toString(), updatedSetup).then( + () => { + setSelectedSourceRef(undefined); + } + ); + return; + } + } + return; + } + + const updatedSetup = removeSetupItem( + selectedSourceRef, + productionSetup + ); + + if (!updatedSetup) return; + + setProductionSetup(updatedSetup); + putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { + setRemoveSourceModal(false); + setSelectedSourceRef(undefined); + }); return; + } const result = await deleteStream( selectedSourceRef.stream_uuids, @@ -524,61 +584,12 @@ export default function ProductionConfiguration({ params }: PageProps) { ); if (!updatedSetup) return; setProductionSetup(updatedSetup); - putProduction(updatedSetup._id.toString(), updatedSetup).then( - () => { - setSelectedSourceRef(undefined); - } - ); + putProduction(updatedSetup._id.toString(), updatedSetup); return; } } return; } - - const updatedSetup = removeSetupItem( - selectedSourceRef, - productionSetup - ); - - if (!updatedSetup) return; - - setProductionSetup(updatedSetup); - putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { - setRemoveSourceModal(false); - setSelectedSourceRef(undefined); - }); - return; - } - - const result = await deleteStream( - selectedSourceRef.stream_uuids, - productionSetup, - selectedSourceRef.input_slot - ); - - if (!result.ok) { - if (!result.value) { - setDeleteSourceStatus({ - success: false, - steps: [{ step: 'unexpected', success: false }] - }); - } else { - setDeleteSourceStatus({ success: false, steps: result.value }); - const didDeleteStream = result.value.some( - (step) => step.step === 'delete_stream' && step.success - ); - if (didDeleteStream) { - const updatedSetup = removeSetupItem( - selectedSourceRef, - productionSetup - ); - if (!updatedSetup) return; - setProductionSetup(updatedSetup); - putProduction(updatedSetup._id.toString(), updatedSetup); - return; - } - } - return; } const updatedSetup = removeSetupItem(selectedSourceRef, productionSetup); if (!updatedSetup) return; @@ -601,6 +612,7 @@ export default function ProductionConfiguration({ params }: PageProps) { setSelectedSource(undefined); setDeleteSourceStatus(undefined); }; + return ( <> @@ -645,6 +657,7 @@ export default function ProductionConfiguration({ params }: PageProps) { @@ -704,11 +717,7 @@ export default function ProductionConfiguration({ params }: PageProps) { updateProduction={(updated) => { updateProduction(productionSetup._id, updated); }} - onSourceUpdate={( - source: SourceReference, - sourceItem: ISource - ) => { - sourceItem.label = source.label; + onSourceUpdate={(source: SourceReference) => { updateSource(source, productionSetup); }} onSourceRemoval={(source: SourceReference) => { @@ -719,6 +728,7 @@ export default function ProductionConfiguration({ params }: PageProps) { const updatedSetup = removeSetupItem( { _id: source._id, + type: source.type, label: source.label, input_slot: source.input_slot }, @@ -748,15 +758,47 @@ export default function ProductionConfiguration({ params }: PageProps) { )} )} - { - setInventoryVisible(true); - }} - /> +
+ setInventoryVisible(true)} + disabled={ + productionSetup?.production_settings === undefined || + productionSetup.production_settings === null + } + /> +
+ - {options.map((value) => ( - - ))} - - ); -}; diff --git a/src/components/modal/AddSourceModal.tsx b/src/components/modal/AddSourceModal.tsx index bacb9656..236b3cb3 100644 --- a/src/components/modal/AddSourceModal.tsx +++ b/src/components/modal/AddSourceModal.tsx @@ -27,7 +27,6 @@ export function AddSourceModal({

{t('workflow.add_source_modal', { name })}

-
{status && }
+ {source && source.src && ( + + )} + {!source && sourceRef && } + {(sourceRef || source) && ( +

+ {t('source.input_slot', { + input_slot: + sourceRef?.input_slot?.toString() || + source?.input_slot?.toString() || + '' + })} +

+ )} + {source && ( +

+ {t('source.ingest', { + ingest: source.ingest_name + })} +

+ )} + {(source || sourceRef) && ( + + )}
); } diff --git a/src/components/sourceCard/SourceThumbnail.tsx b/src/components/sourceCard/SourceThumbnail.tsx index 5aa7114f..b5e5bcbe 100644 --- a/src/components/sourceCard/SourceThumbnail.tsx +++ b/src/components/sourceCard/SourceThumbnail.tsx @@ -2,18 +2,19 @@ import Image from 'next/image'; import { useState } from 'react'; -import { Source } from '../../interfaces/Source'; +import { Source, Type } from '../../interfaces/Source'; import { IconExclamationCircle } from '@tabler/icons-react'; type SourceThumbnailProps = { - source: Source; - src: string; + source?: Source; + src?: string; + type?: Type; }; -export function SourceThumbnail({ source, src }: SourceThumbnailProps) { +export function SourceThumbnail({ source, src, type }: SourceThumbnailProps) { const [loaded, setLoaded] = useState(false); - if (source.status === 'gone') { + if (source && source.status === 'gone') { return (
@@ -22,20 +23,37 @@ export function SourceThumbnail({ source, src }: SourceThumbnailProps) { } return ( - Preview Thumbnail setLoaded(true)} - onError={() => setLoaded(true)} - placeholder="empty" - width={0} - height={0} - sizes="20vh" - style={{ - width: 'auto', - height: '100%' - }} - /> + <> + {(type === 'ingest_source' || !type) && src && ( + Preview Thumbnail setLoaded(true)} + onError={() => setLoaded(true)} + placeholder="empty" + width={0} + height={0} + sizes="20vh" + style={{ + width: 'auto', + height: '100%' + }} + /> + )} + {(type === 'html' || type === 'mediaplayer') && ( + +

+ {type === 'html' ? 'HTML' : 'Media Player'} +

+
+ )} + ); } diff --git a/src/components/sourceCards/SourceCards.tsx b/src/components/sourceCards/SourceCards.tsx index 9666bccc..c20b8c8a 100644 --- a/src/components/sourceCards/SourceCards.tsx +++ b/src/components/sourceCards/SourceCards.tsx @@ -1,13 +1,11 @@ 'use client'; - import React, { useState } from 'react'; import { SourceReference } from '../../interfaces/Source'; import { Production } from '../../interfaces/production'; import DragItem from '../dragElement/DragItem'; import SourceCard from '../sourceCard/SourceCard'; -import { EmptySlotCard } from '../emptySlotCard/EmptySlotCard'; import { ISource, useDragableItems } from '../../hooks/useDragableItems'; - +import { EmptySlotCard } from '../emptySlotCard/EmptySlotCard'; export default function SourceCards({ productionSetup, updateProduction, @@ -16,19 +14,15 @@ export default function SourceCards({ }: { productionSetup: Production; updateProduction: (updated: Production) => void; - onSourceUpdate: (source: SourceReference, sourceItem: ISource) => void; + onSourceUpdate: (source: SourceReference) => void; onSourceRemoval: (source: SourceReference) => void; }) { - const [items, moveItem, loading] = useDragableItems(productionSetup.sources); + const [items, moveItem] = useDragableItems(productionSetup.sources); const [selectingText, setSelectingText] = useState(false); - const currentOrder: SourceReference[] = items.map((source) => { - return { - _id: source._id.toString(), - label: source.label, - input_slot: source.input_slot, - stream_uuids: source.stream_uuids - }; - }); + if (!items) return null; + const isISource = (source: SourceReference | ISource): source is ISource => { + return 'src' in source; + }; const gridItems: React.JSX.Element[] = []; let tempItems = [...items]; @@ -41,59 +35,81 @@ export default function SourceCards({ break; } } + for (let i = 0; i < items[items.length - 1].input_slot; i++) { - // console.log(`On input slot: ${i + 1}`); - // console.log(`Checking sources:`); - // console.log(tempItems); tempItems.every((source) => { + const id = source._id ? source._id : ''; + const isSource = isISource(source); if (source.input_slot === i + 1) { - // console.log(`Found source on input slot: ${i + 1}`); - // console.log(`Removing source "${source.name}" from sources list`); tempItems = tempItems.filter((i) => i._id !== source._id); - // console.log(`Adding source "${source.name}" to grid`); if (!productionSetup.isActive) { gridItems.push( - - setSelectingText(isSelecting) - } - /> + {isSource ? ( + + setSelectingText(isSelecting) + } + type={'ingest_source'} + /> + ) : ( + + setSelectingText(isSelecting) + } + type={source.type} + /> + )} ); } else { - gridItems.push( - - setSelectingText(isSelecting) - } - /> - ); + isSource + ? gridItems.push( + + setSelectingText(isSelecting) + } + type={'ingest_source'} + /> + ) + : gridItems.push( + + setSelectingText(isSelecting) + } + type={source.type} + /> + ); } return false; } else { - // console.log(`No source found on input slot: ${i + 1}`); - // console.log(`Adding empty slot to grid`); if (productionSetup.isActive) { gridItems.push( ); } - return false; } }); diff --git a/src/components/sourceListItem/SourceListItem.tsx b/src/components/sourceListItem/SourceListItem.tsx index 6e9aadf7..c8b724e5 100644 --- a/src/components/sourceListItem/SourceListItem.tsx +++ b/src/components/sourceListItem/SourceListItem.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Source, SourceWithId } from '../../interfaces/Source'; +import { Source, SourceReference, SourceWithId } from '../../interfaces/Source'; import { PreviewThumbnail } from './PreviewThumbnail'; import { getSourceThumbnail } from '../../utils/source'; import videoSettings from '../../utils/videoSettings'; @@ -95,7 +95,7 @@ function InventoryListItem({ : [] ); } - }, [source.audio_stream.audio_mapping]); + }, [source?.audio_stream.audio_mapping]); return (
  • ; disabled: boolean; refreshProduction: () => void; }; export function StartProductionButton({ production, + sources, disabled, refreshProduction }: StartProductionButtonProps) { @@ -45,6 +48,8 @@ export function StartProductionButton({ const onClick = () => { if (!production) return; + console.log('sources', sources); + console.log('production', production); const hasUndefinedPipeline = production.production_settings.pipelines.some( (p) => !p.pipeline_name ); diff --git a/src/hooks/items/addSetupItem.ts b/src/hooks/items/addSetupItem.ts index 2dda22bc..b341c44e 100644 --- a/src/hooks/items/addSetupItem.ts +++ b/src/hooks/items/addSetupItem.ts @@ -14,19 +14,20 @@ export function addSetupItem( ...productionSetup.sources, { _id: source._id, + type: source.type, label: source.label, stream_uuids: source.stream_uuids, input_slot: source.input_slot } ].sort((a, b) => a.input_slot - b.input_slot) }; - return { ...updatedSetup, sources: [ ...productionSetup.sources, { _id: source._id, + type: source.type, label: source.label, stream_uuids: source.stream_uuids, input_slot: source.input_slot diff --git a/src/hooks/pipelines.ts b/src/hooks/pipelines.ts index f9ef473a..baa52ffd 100644 --- a/src/hooks/pipelines.ts +++ b/src/hooks/pipelines.ts @@ -33,6 +33,7 @@ export function usePipeline( setLoading(true); getPipeline(id) .then((pipeline) => { + console.log('pipeline', pipeline); setPipeline(pipeline); }) .catch((error) => { diff --git a/src/hooks/productions.ts b/src/hooks/productions.ts index e3164fe9..cdfe918e 100644 --- a/src/hooks/productions.ts +++ b/src/hooks/productions.ts @@ -10,8 +10,7 @@ export function usePostProduction() { body: JSON.stringify({ isActive: false, name, - sources: [], - selectedPresetRef: undefined + sources: [] }) }); if (response.ok) { @@ -36,7 +35,7 @@ export function useGetProduction() { } export function usePutProduction() { - return async (id: string, production: Production): Promise => { + return async (id: string, production: Production): Promise => { const response = await fetch(`/api/manager/productions/${id}`, { method: 'PUT', // TODO: Implement api key @@ -44,7 +43,7 @@ export function usePutProduction() { body: JSON.stringify(production) }); if (response.ok) { - return; + return response.json(); } throw await response.text(); }; diff --git a/src/hooks/sources/useAddSource.tsx b/src/hooks/sources/useAddSource.tsx new file mode 100644 index 00000000..cdd66d61 --- /dev/null +++ b/src/hooks/sources/useAddSource.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import { addSetupItem } from '../items/addSetupItem'; +import { CallbackHook } from '../types'; +import { Production } from '../../interfaces/production'; +import { usePutProduction } from '../productions'; +import { SourceReference } from '../../interfaces/Source'; + +export function useAddSource(): CallbackHook< + ( + input: SourceReference, + productionSetup: Production + ) => Promise +> { + const [loading, setLoading] = useState(true); + const putProduction = usePutProduction(); + + const addSource = async ( + input: SourceReference, + productionSetup: Production + ) => { + const updatedSetup = addSetupItem( + { + _id: input._id ? input._id : undefined, + type: input.type, + label: input.label, + input_slot: input.input_slot + }, + productionSetup + ); + + if (!updatedSetup) return; + + const res = await putProduction(updatedSetup._id.toString(), updatedSetup); + return res; + }; + + return [addSource, loading]; +} diff --git a/src/hooks/sources/useSources.tsx b/src/hooks/sources/useSources.tsx index 1b58418a..8b44ea29 100644 --- a/src/hooks/sources/useSources.tsx +++ b/src/hooks/sources/useSources.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { SourceWithId } from '../../interfaces/Source'; export function useSources( - deleteComplete?: boolean, + reloadList?: boolean, updatedSource?: SourceWithId ): [Map, boolean] { const [sources, setSources] = useState>( @@ -11,7 +11,7 @@ export function useSources( const [loading, setLoading] = useState(true); useEffect(() => { - if (!updatedSource || deleteComplete) { + if (!updatedSource || reloadList) { fetch('/api/manager/sources?mocked=false', { method: 'GET', // TODO: Implement api key @@ -34,6 +34,6 @@ export function useSources( } sources.set(updatedSource._id.toString(), updatedSource); setSources(new Map(sources)); - }, [updatedSource, deleteComplete]); + }, [updatedSource, reloadList]); return [sources, loading]; } diff --git a/src/hooks/useDragableItems.ts b/src/hooks/useDragableItems.ts index 427ffbf1..a31a09ea 100644 --- a/src/hooks/useDragableItems.ts +++ b/src/hooks/useDragableItems.ts @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { SourceReference, SourceWithId } from '../interfaces/Source'; import { useSources } from './sources/useSources'; import { getSourceThumbnail } from '../utils/source'; - export interface ISource extends SourceWithId { label: string; input_slot: number; @@ -11,57 +10,79 @@ export interface ISource extends SourceWithId { } export function useDragableItems( sources: SourceReference[] -): [ISource[], (originId: string, destinationId: string) => void, boolean] { +): [ + (SourceReference | ISource)[], + (originId: string, destinationId: string) => void, + boolean +] { const [inventorySources, loading] = useSources(); - const [items, setItems] = useState( + const [items, setItems] = useState<(SourceReference | ISource)[]>( sources.flatMap((ref) => { - const source = inventorySources.get(ref._id); + const refId = ref._id ? ref._id : ''; + const source = inventorySources.get(refId); if (!source) return []; return { ...source, + _id: refId, label: ref.label, input_slot: ref.input_slot, stream_uuids: ref.stream_uuids, - src: getSourceThumbnail(source) + src: getSourceThumbnail(source), + ingest_source_name: source.ingest_source_name, + ingest_name: source.ingest_name, + video_stream: source.video_stream, + audio_stream: source.audio_stream, + status: source.status, + type: source.type, + tags: source.tags, + name: source.name }; }) ); - useEffect(() => { - setItems( - sources.flatMap((ref) => { - const source = inventorySources.get(ref._id); - if (!source) return []; - return { - ...source, - label: ref.label, - input_slot: ref.input_slot, - stream_uuids: ref.stream_uuids, - src: getSourceThumbnail(source) - }; - }) - ); + const updatedItems = sources.map((ref) => { + const refId = ref._id ? ref._id : ''; + const source = inventorySources.get(refId); + if (!source) return { ...ref }; + return { + ...ref, + _id: refId, + status: source.status, + name: source.name, + type: source.type, + tags: source.tags, + ingest_name: source.ingest_name, + ingest_source_name: source.ingest_source_name, + ingest_type: source.ingest_type, + label: ref.label, + input_slot: ref.input_slot, + stream_uuids: ref.stream_uuids, + src: getSourceThumbnail(source), + video_stream: source.video_stream, + audio_stream: source.audio_stream, + lastConnected: source.lastConnected + }; + }); + setItems(updatedItems); }, [sources, inventorySources]); - const moveItem = (originId: string, destinationId: string) => { - const originSource = items.find((i) => i._id.toString() === originId); + const originSource = items.find( + (item) => (item._id ? item._id.toString() : '') === originId + ); const destinationSource = items.find( - (i) => i._id.toString() === destinationId + (item) => (item._id ? item._id.toString() : '') === destinationId ); if (!originSource || !destinationSource) return; - const originInputSlot = originSource.input_slot; - const destinationInputSlot = destinationSource.input_slot; - originSource.input_slot = destinationInputSlot; - destinationSource.input_slot = originInputSlot; - const updatedItems = [ - ...items.filter( - (i) => i._id !== originSource._id && i._id !== destinationSource._id - ), - originSource, - destinationSource - ].sort((a, b) => a.input_slot - b.input_slot); + const updatedItems = items + .map((item) => { + if (item._id === originSource._id) + return { ...item, input_slot: destinationSource.input_slot }; + if (item._id === destinationSource._id) + return { ...item, input_slot: originSource.input_slot }; + return item; + }) + .sort((a, b) => a.input_slot - b.input_slot); setItems(updatedItems); }; - return [items, moveItem, loading]; } diff --git a/src/hooks/useGetFirstEmptySlot.ts b/src/hooks/useGetFirstEmptySlot.ts new file mode 100644 index 00000000..8cda1821 --- /dev/null +++ b/src/hooks/useGetFirstEmptySlot.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { Production } from '../interfaces/production'; +import { CallbackHook } from './types'; + +export function useGetFirstEmptySlot(): CallbackHook< + (productionSetup?: Production | undefined) => number +> { + const [loading, setLoading] = useState(true); + + const findFirstEmptySlot = (productionSetup: Production | undefined) => { + if (!productionSetup) throw 'no_production'; + + if (productionSetup) { + let firstEmptySlotTemp = productionSetup.sources.length + 1; + if (productionSetup.sources.length === 0) { + return firstEmptySlotTemp; + } + for ( + let i = 0; + i < + productionSetup.sources[productionSetup.sources.length - 1].input_slot; + i++ + ) { + if ( + !productionSetup.sources.some((source) => source.input_slot === i + 1) + ) { + firstEmptySlotTemp = i + 1; + break; + } + } + return firstEmptySlotTemp; + } else { + return 0; + } + }; + return [findFirstEmptySlot, loading]; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1d0e7e57..596e25c8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -46,7 +46,8 @@ export const en = { orig: 'Original Name: {{name}}', metadata: 'Source Metadata', location_unknown: 'Unknown', - last_connected: 'Last connection' + last_connected: 'Last connection', + input_slot: 'Input slot: {{input_slot}}' }, delete_source_status: { delete_stream: 'Delete stream', @@ -63,14 +64,17 @@ export const en = { }, production_configuration: 'Production Configuration', production: { - add_source: 'Add Source', + add_source: 'Add ingest', select_preset: 'Select Preset', clear_selection: 'Clear Selection', started: 'Production started: {{name}}', failed: 'Production start failed: {{name}}', stopped: 'Production stopped: {{name}}', stop_failed: 'Production stop failed: {{name}}', - missing_multiview: 'Missing multiview reference in selected preset' + missing_multiview: 'Missing multiview reference in selected preset', + source: 'Source', + add: 'Add', + add_other_source_type: 'Add other source type' }, create_new: 'Create New', default_prod_placeholder: 'My New Configuration', diff --git a/src/i18n/locales/sv.ts b/src/i18n/locales/sv.ts index b33112df..bf9f42c8 100644 --- a/src/i18n/locales/sv.ts +++ b/src/i18n/locales/sv.ts @@ -48,7 +48,8 @@ export const sv = { orig: 'Enhetsnamn: {{name}}', metadata: 'Käll-metadata', location_unknown: 'Okänd', - last_connected: 'Senast uppkoppling' + last_connected: 'Senast uppkoppling', + input_slot: 'Ingång: {{input_slot}}' }, delete_source_status: { delete_stream: 'Radera ström', @@ -65,14 +66,17 @@ export const sv = { }, production_configuration: 'Produktionskonfiguration', production: { - add_source: 'Lägg till källa', + add_source: 'Lägg till ingång', select_preset: 'Välj produktionsmall', clear_selection: 'Rensa val', started: 'Produktion startad: {{name}}', failed: 'Start av produktion misslyckades: {{name}}', stopped: 'Produktion stoppad: {{name}}', stop_failed: 'Stopp av produktion misslyckades: {{name}}', - missing_multiview: 'Saknar referens till en multiview i valt preset' + missing_multiview: 'Saknar referens till en multiview i valt preset', + source: 'Källa', + add: 'Lägg till', + add_other_source_type: 'Lägg till annan källtyp' }, create_new: 'Skapa ny', default_prod_placeholder: 'Min Nya Konfiguration', diff --git a/src/interfaces/Source.ts b/src/interfaces/Source.ts index e59afa4a..2e9935b6 100644 --- a/src/interfaces/Source.ts +++ b/src/interfaces/Source.ts @@ -1,6 +1,7 @@ import { ObjectId, WithId } from 'mongodb'; export type SourceType = 'camera' | 'graphics' | 'microphone'; export type SourceStatus = 'ready' | 'new' | 'gone' | 'purge'; +export type Type = 'ingest_source' | 'html' | 'mediaplayer'; export type VideoStream = { height?: number; width?: number; @@ -16,7 +17,7 @@ export type AudioStream = { export type Numbers = number | number[]; export interface Source { - _id?: ObjectId; + _id?: ObjectId | string; status: SourceStatus; name: string; type: SourceType; @@ -33,7 +34,8 @@ export interface Source { } export interface SourceReference { - _id: string; + _id?: string; + type: Type; label: string; stream_uuids?: string[]; input_slot: number; diff --git a/src/middleware.ts b/src/middleware.ts index 7724e3b7..cac08478 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -41,4 +41,4 @@ export default withAuth(function middleware(req) { } }); -export const config = { matcher: ['/', '/((?!api|images).*)/'] }; +export const config = { matcher: ['/', '/((?!api|images|html_input).*)/'] }; From f49f98ce5161f06cd1860a9b38f003bb7cc3160b Mon Sep 17 00:00:00 2001 From: Saelmala Date: Tue, 10 Sep 2024 15:50:10 +0200 Subject: [PATCH 02/82] feat: add management lock --- src/app/inventory/page.tsx | 21 +---- src/app/page.tsx | 18 ++-- src/app/production/[id]/page.tsx | 87 +++++++------------ src/components/button/Button.tsx | 7 +- src/components/button/MonitoringButton.tsx | 10 ++- .../createProduction/CreateProduction.tsx | 29 +++++-- .../headerNavigation/HeaderNavigation.tsx | 2 +- .../homePageContent/HomePageContent.tsx | 28 ++++++ src/components/inventory/Inventory.tsx | 8 +- .../inventory/InventoryPageContent.tsx | 32 +++++++ .../editView/AudioChannels/AudioChannels.tsx | 4 +- .../editView/AudioChannels/NumberInput.tsx | 2 +- .../editView/AudioChannels/Outputs.tsx | 10 ++- .../inventory/editView/EditView.tsx | 23 +++-- .../inventory/editView/GeneralSettings.tsx | 19 +++- .../inventory/editView/SelectOptions.tsx | 27 ++++-- .../inventory/editView/UpdateButtons.tsx | 44 +++++++--- src/components/lockButton/LockButton.tsx | 23 +++++ .../DeleteProductionButton.tsx | 12 +-- .../productionsList/ProductionsList.tsx | 12 ++- .../productionsList/ProductionsListItem.tsx | 24 +++-- src/components/sourceCard/SourceCard.tsx | 85 ++++++------------ src/components/sourceCards/SourceCards.tsx | 79 ++++++----------- .../sourceListItem/SourceListItem.tsx | 7 +- .../startProduction/ConfigureOutputButton.tsx | 7 +- .../startProduction/StartProductionButton.tsx | 13 ++- 26 files changed, 363 insertions(+), 270 deletions(-) create mode 100644 src/components/homePageContent/HomePageContent.tsx create mode 100644 src/components/inventory/InventoryPageContent.tsx create mode 100644 src/components/lockButton/LockButton.tsx diff --git a/src/app/inventory/page.tsx b/src/app/inventory/page.tsx index 0b851641..a762e9da 100644 --- a/src/app/inventory/page.tsx +++ b/src/app/inventory/page.tsx @@ -1,22 +1,5 @@ -import { Suspense } from 'react'; -import HeaderNavigation from '../../components/headerNavigation/HeaderNavigation'; -import { useTranslate } from '../../i18n/useTranslate'; -import { LoadingCover } from '../../components/loader/LoadingCover'; -import Inventory from '../../components/inventory/Inventory'; +import { InventoryPageContent } from '../../components/inventory/InventoryPageContent'; export default function Page() { - const t = useTranslate(); - - return ( - <> - -

    - {t('inventory')} -

    -
    - }> - - - - ); + return ; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 30467a92..f6ad0174 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,15 @@ -import React, { Suspense } from 'react'; -import ProductionsList from '../components/productionsList/ProductionsList'; -import { CreateProduction } from '../components/createProduction/CreateProduction'; -import { LoadingCover } from '../components/loader/LoadingCover'; +import React from 'react'; import Link from 'next/link'; import { Button } from '../components/button/Button'; import { useTranslate } from '../i18n/useTranslate'; +import { getProductions } from '../api/manager/productions'; +import { HomePageContent } from '../components/homePageContent/HomePageContent'; export const dynamic = 'force-dynamic'; -function Home() { +async function Home() { const t = useTranslate(); + const productions = await getProductions(); return ( <>
    @@ -22,13 +22,7 @@ function Home() {
  • -
    - -
    - }> - {/* @ts-expect-error Async Server Component: https://github.com/vercel/next.js/issues/42292 */} - - +
    ); diff --git a/src/app/production/[id]/page.tsx b/src/app/production/[id]/page.tsx index 4fc3cd06..06d7c437 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -43,9 +43,7 @@ import { useDeleteStream, useCreateStream } from '../../../hooks/streams'; import { MonitoringButton } from '../../../components/button/MonitoringButton'; import { useGetMultiviewPreset } from '../../../hooks/multiviewPreset'; import { useMultiviews } from '../../../hooks/multiviews'; -import { useAddSource } from '../../../hooks/sources/useAddSource'; -import { useGetFirstEmptySlot } from '../../../hooks/useGetFirstEmptySlot'; -import { Select } from '../../../components/select/Select'; +import { LockButton } from '../../../components/lockButton/LockButton'; export default function ProductionConfiguration({ params }: PageProps) { const t = useTranslate(); @@ -95,12 +93,7 @@ export default function ProductionConfiguration({ params }: PageProps) { const [deleteSourceStatus, setDeleteSourceStatus] = useState(); - // Create source - const [firstEmptySlot] = useGetFirstEmptySlot(); - const [addSource] = useAddSource(); - - const isAddButtonDisabled = - selectedValue !== 'HTML' && selectedValue !== 'Media Player'; + const [isLocked, setIsLocked] = useState(true); useEffect(() => { refreshPipelines(); @@ -411,6 +404,7 @@ export default function ProductionConfiguration({ params }: PageProps) { key={`${source.ingest_source_name}-${index}`} source={source} disabled={selectedProductionItems?.includes(source._id.toString())} + isLocked={isLocked} action={(source: SourceWithId) => { if (productionSetup && productionSetup.isActive) { setSelectedSource(source); @@ -629,14 +623,21 @@ export default function ProductionConfiguration({ params }: PageProps) { } }} onBlur={() => updateConfigName(configurationName)} + disabled={isLocked} />
    + setIsLocked(!isLocked)} + />
    @@ -745,6 +745,7 @@ export default function ProductionConfiguration({ params }: PageProps) { }); } }} + isLocked={isLocked} /> {removeSourceModal && selectedSourceRef && ( )} -
    - setInventoryVisible(true)} - disabled={ - productionSetup?.production_settings === undefined || - productionSetup.production_settings === null - } - /> -
    - {options.map((option: string, index: number) => ( diff --git a/src/components/inventory/editView/UpdateButtons.tsx b/src/components/inventory/editView/UpdateButtons.tsx index 1f3309da..622f7602 100644 --- a/src/components/inventory/editView/UpdateButtons.tsx +++ b/src/components/inventory/editView/UpdateButtons.tsx @@ -7,15 +7,19 @@ import { Loader } from '../../loader/Loader'; import { SourceWithId } from '../../../interfaces/Source'; import { IconTrash } from '@tabler/icons-react'; +type UpdateButtonsProps = { + source: SourceWithId; + isLocked: boolean; + removeInventorySource: (source: SourceWithId) => void; + close: () => void; +}; + export default function UpdateButtons({ + source, + isLocked, close, - removeInventorySource, - source -}: { - close: () => void; - removeInventorySource: (source: SourceWithId) => void; - source: SourceWithId; -}) { + removeInventorySource +}: UpdateButtonsProps) { const t = useTranslate(); const { saved: [saved], @@ -25,7 +29,7 @@ export default function UpdateButtons({ return (
    -
    +
    {t('saved')}
    @@ -35,16 +39,32 @@ export default function UpdateButtons({ - + ); +}; diff --git a/src/components/productionsList/DeleteProductionButton.tsx b/src/components/productionsList/DeleteProductionButton.tsx index a0dfe898..f4cada82 100644 --- a/src/components/productionsList/DeleteProductionButton.tsx +++ b/src/components/productionsList/DeleteProductionButton.tsx @@ -11,12 +11,14 @@ type DeleteProductionButtonProps = { id: string; name: string; isActive: boolean; + isLocked: boolean; }; export function DeleteProductionButton({ id, name, - isActive + isActive, + isLocked }: DeleteProductionButtonProps) { const router = useRouter(); const deleteProduction = useDeleteProduction(); @@ -38,12 +40,12 @@ export function DeleteProductionButton({ <> - )} + +

    + {t('source.ingest', { + ingest: source.ingest_name + })} +

    +
    ); } diff --git a/src/components/sourceCards/SourceCards.tsx b/src/components/sourceCards/SourceCards.tsx index c20b8c8a..0d4b2136 100644 --- a/src/components/sourceCards/SourceCards.tsx +++ b/src/components/sourceCards/SourceCards.tsx @@ -10,12 +10,14 @@ export default function SourceCards({ productionSetup, updateProduction, onSourceUpdate, - onSourceRemoval + onSourceRemoval, + isLocked }: { productionSetup: Production; updateProduction: (updated: Production) => void; onSourceUpdate: (source: SourceReference) => void; onSourceRemoval: (source: SourceReference) => void; + isLocked: boolean; }) { const [items, moveItem] = useDragableItems(productionSetup.sources); const [selectingText, setSelectingText] = useState(false); @@ -54,59 +56,34 @@ export default function SourceCards({ updateProduction={updateProduction} selectingText={selectingText} > - {isSource ? ( - - setSelectingText(isSelecting) - } - type={'ingest_source'} - /> - ) : ( - - setSelectingText(isSelecting) - } - type={source.type} - /> - )} + + setSelectingText(isSelecting) + } + isLocked={isLocked} + /> ); } else { - isSource - ? gridItems.push( - - setSelectingText(isSelecting) - } - type={'ingest_source'} - /> - ) - : gridItems.push( - - setSelectingText(isSelecting) - } - type={source.type} - /> - ); + gridItems.push( + + setSelectingText(isSelecting) + } + isLocked={isLocked} + /> + ); } return false; } else { diff --git a/src/components/sourceListItem/SourceListItem.tsx b/src/components/sourceListItem/SourceListItem.tsx index c8b724e5..5a1521bd 100644 --- a/src/components/sourceListItem/SourceListItem.tsx +++ b/src/components/sourceListItem/SourceListItem.tsx @@ -14,9 +14,10 @@ import capitalize from '../../utils/capitalize'; type SourceListItemProps = { source: SourceWithId; - action: (source: SourceWithId) => void; edit?: boolean; disabled: unknown; + isLocked: boolean; + action: (source: SourceWithId) => void; }; const getIcon = (source: Source) => { @@ -51,7 +52,8 @@ function InventoryListItem({ source, action, disabled, - edit = false + edit = false, + isLocked }: SourceListItemProps) { const t = useTranslate(); const [previewVisible, setPreviewVisible] = useState(false); @@ -169,6 +171,7 @@ function InventoryListItem({ outputRows={outputRows} rowIndex={rowIndex} max={channelsInArray[channelsInArray.length - 1]} + isLocked={isLocked} />
    ))} diff --git a/src/components/startProduction/ConfigureOutputButton.tsx b/src/components/startProduction/ConfigureOutputButton.tsx index 1ded9838..cee39ab1 100644 --- a/src/components/startProduction/ConfigureOutputButton.tsx +++ b/src/components/startProduction/ConfigureOutputButton.tsx @@ -6,6 +6,7 @@ import { Preset } from '../../interfaces/preset'; import { useTranslate } from '../../i18n/useTranslate'; import { Button } from '../button/Button'; import { ConfigureOutputModal } from '../modal/configureOutputModal/ConfigureOutputModal'; + type ConfigureOutputButtonProps = { preset?: Preset; disabled?: boolean; @@ -30,9 +31,11 @@ export function ConfigureOutputButton({ onClick={toggleConfigModal} disabled={!preset || disabled} hoverMessage={!preset ? t('preset.preset_necessary') : ''} - className={`min-w-fit`} + className={`min-w-fit ${ + disabled ? 'bg-button-bg/50 pointer-events-none' : 'bg-button-bg' + }`} > - + {preset && ( From 20b52e8304dc5449d30fa31077eac3b6aaa918be Mon Sep 17 00:00:00 2001 From: Lucas Maupin Date: Mon, 16 Sep 2024 09:50:31 +0200 Subject: [PATCH 03/82] feat: display thumbnail on card and fix cache issue --- src/api/ateliereLive/ingest.ts | 4 +- .../ateliereLive/pipelines/streams/streams.ts | 34 ++-- src/api/manager/workflow.ts | 7 +- .../[source_name]/thumbnail/route.ts | 1 - src/app/api/manager/streams/route.ts | 1 - src/app/api/manager/websocket/route.ts | 38 ++++ src/app/layout.tsx | 5 +- src/app/production/[id]/page.tsx | 175 ++++++++---------- src/components/button/MonitoringButton.tsx | 10 +- .../createProduction/CreateProduction.tsx | 22 +-- src/components/filter/FilterOptions.tsx | 11 +- .../headerNavigation/HeaderNavigation.tsx | 18 +- .../homePageContent/HomePageContent.tsx | 12 +- src/components/image/ImageComponent.tsx | 111 +++++++++++ src/components/inventory/Inventory.tsx | 99 +++------- .../inventory/InventoryPageContent.tsx | 12 +- .../editView/AudioChannels/AudioChannels.tsx | 6 +- .../editView/AudioChannels/Outputs.tsx | 6 +- .../inventory/editView/EditView.tsx | 59 ++---- .../inventory/editView/GeneralSettings.tsx | 14 +- .../inventory/editView/UpdateButtons.tsx | 18 +- src/components/lockButton/LockButton.tsx | 16 +- src/components/modal/AddSourceModal.tsx | 6 +- src/components/modal/Modal.tsx | 2 +- .../DeleteProductionButton.tsx | 9 +- .../productionsList/ProductionsList.tsx | 6 +- .../productionsList/ProductionsListItem.tsx | 20 +- src/components/sourceCard/SourceCard.tsx | 90 +++++---- src/components/sourceCard/SourceThumbnail.tsx | 59 ------ src/components/sourceCards/SourceCards.tsx | 28 ++- .../SourceList.module.scss} | 0 src/components/sourceList/SourceList.tsx | 90 +++++++++ .../sourceListItem/PreviewThumbnail.tsx | 31 ---- .../sourceListItem/SourceListItem.tsx | 78 +++----- .../SourceListItemThumbnail.tsx | 50 +++++ .../startProduction/StartProductionButton.tsx | 5 - .../inventory => contexts}/FilterContext.tsx | 2 +- src/contexts/GlobalContext.tsx | 50 +++++ src/hooks/pipelines.ts | 1 - src/hooks/sources/useAddSource.tsx | 2 +- src/hooks/streams.ts | 10 +- src/hooks/useWebsocket.ts | 20 ++ src/i18n/locales/en.ts | 1 + src/i18n/locales/sv.ts | 1 + 44 files changed, 700 insertions(+), 540 deletions(-) create mode 100644 src/app/api/manager/websocket/route.ts create mode 100644 src/components/image/ImageComponent.tsx delete mode 100644 src/components/sourceCard/SourceThumbnail.tsx rename src/components/{inventory/Inventory.module.scss => sourceList/SourceList.module.scss} (100%) create mode 100644 src/components/sourceList/SourceList.tsx delete mode 100644 src/components/sourceListItem/PreviewThumbnail.tsx create mode 100644 src/components/sourceListItem/SourceListItemThumbnail.tsx rename src/{components/inventory => contexts}/FilterContext.tsx (95%) create mode 100644 src/contexts/GlobalContext.tsx create mode 100644 src/hooks/useWebsocket.ts diff --git a/src/api/ateliereLive/ingest.ts b/src/api/ateliereLive/ingest.ts index 0c7b5218..396ef5a1 100644 --- a/src/api/ateliereLive/ingest.ts +++ b/src/api/ateliereLive/ingest.ts @@ -106,6 +106,7 @@ export async function getSourceThumbnail( process.env.LIVE_URL ), { + next: { tags: ['image'] }, method: 'POST', body: JSON.stringify({ encoder: 'auto', @@ -114,7 +115,8 @@ export async function getSourceThumbnail( width }), headers: { - authorization: getAuthorizationHeader() + authorization: getAuthorizationHeader(), + cache: 'no-store' } } ); diff --git a/src/api/ateliereLive/pipelines/streams/streams.ts b/src/api/ateliereLive/pipelines/streams/streams.ts index fc5e108f..f831d377 100644 --- a/src/api/ateliereLive/pipelines/streams/streams.ts +++ b/src/api/ateliereLive/pipelines/streams/streams.ts @@ -66,6 +66,7 @@ export async function createStream( return pipeline.uuid; }) ); + const ingestUuid = await getUuidFromIngestName( source.ingest_name, false @@ -79,6 +80,7 @@ export async function createStream( source.ingest_source_name, false ); + const audioMapping = source.audio_stream.audio_mapping && source.audio_stream.audio_mapping.length > 0 @@ -86,6 +88,7 @@ export async function createStream( : [[0, 1]]; await initDedicatedPorts(); + for (const pipeline of production_settings.pipelines) { const availablePorts = getAvailablePortsForIngest( source.ingest_name, @@ -101,28 +104,29 @@ export async function createStream( Log().info( `Allocated port ${availablePort} on '${source.ingest_name}' for ${source.ingest_source_name}` ); + const stream: PipelineStreamSettings = { + ingest_id: ingestUuid, + source_id: sourceId, pipeline_id: pipeline.pipeline_id!, + input_slot: input_slot, alignment_ms: pipeline.alignment_ms, - audio_format: pipeline.audio_format, - audio_sampling_frequency: pipeline.audio_sampling_frequency, - bit_depth: pipeline.bit_depth, - convert_color_range: pipeline.convert_color_range, - encoder: pipeline.encoder, - encoder_device: pipeline.encoder_device, - format: pipeline.format, + max_network_latency_ms: pipeline.max_network_latency_ms, + width: pipeline.width, + height: pipeline.height, frame_rate_d: pipeline.frame_rate_d, frame_rate_n: pipeline.frame_rate_n, + format: pipeline.format, + encoder: pipeline.encoder, + encoder_device: pipeline.encoder_device, gop_length: pipeline.gop_length, - height: pipeline.height, - max_network_latency_ms: pipeline.max_network_latency_ms, pic_mode: pipeline.pic_mode, - speed_quality_balance: pipeline.speed_quality_balance, video_kilobit_rate: pipeline.video_kilobit_rate, - width: pipeline.width, - ingest_id: ingestUuid, - source_id: sourceId, - input_slot, + bit_depth: pipeline.bit_depth, + speed_quality_balance: pipeline.speed_quality_balance, + convert_color_range: pipeline.convert_color_range, + audio_sampling_frequency: pipeline.audio_sampling_frequency, + audio_format: pipeline.audio_format, audio_mapping: JSON.stringify(audioMapping), interfaces: [ { @@ -131,6 +135,7 @@ export async function createStream( } ] }; + try { Log().info( `Connecting '${source.ingest_name}/${ingestUuid}}:${source.ingest_source_name}' to '${pipeline.pipeline_name}/${pipeline.pipeline_id}'` @@ -147,6 +152,7 @@ export async function createStream( Log().info( `Stream '${result.stream_uuid}' from '${source.ingest_name}/${ingestUuid}' to '${pipeline.pipeline_name}/${pipeline.pipeline_id}' connected` ); + sourceToPipelineStreams.push({ source_id: source._id.toString(), stream_uuid: result.stream_uuid, diff --git a/src/api/manager/workflow.ts b/src/api/manager/workflow.ts index 7b9e392f..c05a41fe 100644 --- a/src/api/manager/workflow.ts +++ b/src/api/manager/workflow.ts @@ -137,7 +137,7 @@ async function connectIngestSources( width: pipeline.width, ingest_id: ingestUuid, source_id: sourceId, - input_slot, + input_slot: input_slot, audio_mapping: JSON.stringify(audioMapping), interfaces: [ { @@ -492,7 +492,10 @@ export async function startProduction( source.type !== 'mediaplayer' ) .map((source) => { - return source._id!.toString(); + if (source._id !== undefined) { + return source._id.toString(); + } + return ''; }) ).catch((error) => { if (error === "Can't connect to Database") { diff --git a/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts b/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts index 926d3e33..8d69748f 100644 --- a/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts +++ b/src/app/api/manager/sources/[ingest_name]/[source_name]/thumbnail/route.ts @@ -20,7 +20,6 @@ export async function GET( status: 403 }); } - try { const ingestUuid = await getUuidFromIngestName(params.ingest_name); const sourceId = await getSourceIdFromSourceName( diff --git a/src/app/api/manager/streams/route.ts b/src/app/api/manager/streams/route.ts index 44635884..06c7ff6a 100644 --- a/src/app/api/manager/streams/route.ts +++ b/src/app/api/manager/streams/route.ts @@ -15,7 +15,6 @@ export async function POST(request: NextRequest): Promise { status: 403 }); } - const data = await request.json(); const createStreamRequest = data as CreateStreamRequestBody; return await createStream( diff --git a/src/app/api/manager/websocket/route.ts b/src/app/api/manager/websocket/route.ts new file mode 100644 index 00000000..1ff9789c --- /dev/null +++ b/src/app/api/manager/websocket/route.ts @@ -0,0 +1,38 @@ +import { NextResponse, NextRequest } from 'next/server'; + +const wsUrl = `ws://${process.env.CONTROL_PANEL_WS}`; + +export async function POST(request: NextRequest): Promise { + const { action, inputSlot } = await request.json(); + + if (!wsUrl) { + return NextResponse.json( + { message: 'WebSocket URL is not defined' }, + { status: 500 } + ); + } + + return new Promise((resolve) => { + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + if (action === 'closeHtml') { + ws.send(`html close ${inputSlot}`); + ws.send('html reset'); + } else if (action === 'closeMediaplayer') { + ws.send(`media close ${inputSlot}`); + ws.send('media reset'); + } + ws.close(); + }; + + ws.onerror = (error) => { + resolve( + NextResponse.json( + { message: 'WebSocket error', error }, + { status: 500 } + ) + ); + }; + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eab1456f..622ac7a0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import { Toaster } from 'react-hot-toast'; import DefaultLayout from '../components/layout/DefaultLayout'; import './globals.css'; +import GlobalContextProvider from '../contexts/GlobalContext'; export default async function RootLayout({ children @@ -21,7 +22,9 @@ export default async function RootLayout({ } }} /> - {children} + + {children} + ); diff --git a/src/app/production/[id]/page.tsx b/src/app/production/[id]/page.tsx index 06d7c437..2a66a85f 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -1,11 +1,8 @@ 'use client'; -import React, { useEffect, useState, KeyboardEvent } from 'react'; +import React, { useEffect, useState, KeyboardEvent, useContext } from 'react'; import { PageProps } from '../../../../.next/types/app/production/[id]/page'; -import SourceListItem from '../../../components/sourceListItem/SourceListItem'; -import FilterOptions from '../../../components/filter/FilterOptions'; import { AddInput } from '../../../components/addInput/AddInput'; -import { IconX } from '@tabler/icons-react'; import { useSources } from '../../../hooks/sources/useSources'; import { AddSourceStatus, @@ -20,8 +17,6 @@ import { updateSetupItem } from '../../../hooks/items/updateSetupItem'; import { removeSetupItem } from '../../../hooks/items/removeSetupItem'; import { addSetupItem } from '../../../hooks/items/addSetupItem'; import HeaderNavigation from '../../../components/headerNavigation/HeaderNavigation'; -import styles from './page.module.scss'; -import FilterProvider from '../../../components/inventory/FilterContext'; import { useGetPresets } from '../../../hooks/presets'; import { Preset } from '../../../interfaces/preset'; import SourceCards from '../../../components/sourceCards/SourceCards'; @@ -43,7 +38,13 @@ import { useDeleteStream, useCreateStream } from '../../../hooks/streams'; import { MonitoringButton } from '../../../components/button/MonitoringButton'; import { useGetMultiviewPreset } from '../../../hooks/multiviewPreset'; import { useMultiviews } from '../../../hooks/multiviews'; +import SourceList from '../../../components/sourceList/SourceList'; import { LockButton } from '../../../components/lockButton/LockButton'; +import { GlobalContext } from '../../../contexts/GlobalContext'; +import { Select } from '../../../components/select/Select'; +import { useAddSource } from '../../../hooks/sources/useAddSource'; +import { useGetFirstEmptySlot } from '../../../hooks/useGetFirstEmptySlot'; +import { useWebsocket } from '../../../hooks/useWebsocket'; export default function ProductionConfiguration({ params }: PageProps) { const t = useTranslate(); @@ -93,7 +94,14 @@ export default function ProductionConfiguration({ params }: PageProps) { const [deleteSourceStatus, setDeleteSourceStatus] = useState(); - const [isLocked, setIsLocked] = useState(true); + // Create source + const [firstEmptySlot] = useGetFirstEmptySlot(); + const [addSource] = useAddSource(); + + // Websocket + const [closeWebsocket] = useWebsocket(); + + const { locked } = useContext(GlobalContext); useEffect(() => { refreshPipelines(); @@ -394,40 +402,29 @@ export default function ProductionConfiguration({ params }: PageProps) { ); } - // Adding source to a production, both in setup-mode and in live-mode - function getSourcesToDisplay( - filteredSources: Map - ): React.ReactNode[] { - return Array.from(filteredSources.values()).map((source, index) => { - return ( - { - if (productionSetup && productionSetup.isActive) { - setSelectedSource(source); - setAddSourceModal(true); - } else if (productionSetup) { - const input: SourceReference = { - _id: source._id.toString(), - type: 'ingest_source', - label: source.ingest_source_name, - input_slot: firstEmptySlot(productionSetup) - }; - addSource(input, productionSetup).then((updatedSetup) => { - if (!updatedSetup) return; - setProductionSetup(updatedSetup); - setAddSourceModal(false); - setSelectedSource(undefined); - }); - } - }} - /> - ); - }); - } + const addSourceAction = (source: SourceWithId) => { + if (productionSetup && productionSetup.isActive) { + setSelectedSource(source); + setAddSourceModal(true); + } else if (productionSetup) { + const input: SourceReference = { + _id: source._id.toString(), + type: 'ingest_source', + label: source.ingest_source_name, + input_slot: firstEmptySlot(productionSetup) + }; + addSource(input, productionSetup).then((updatedSetup) => { + if (!updatedSetup) return; + setProductionSetup(updatedSetup); + setAddSourceModal(false); + setSelectedSource(undefined); + }); + } + }; + + const isDisabledFunction = (source: SourceWithId): boolean => { + return selectedProductionItems?.includes(source._id.toString()); + }; const handleAddSource = async () => { setAddSourceStatus(undefined); @@ -585,7 +582,25 @@ export default function ProductionConfiguration({ params }: PageProps) { return; } } + + if ( + selectedSourceRef.type === 'html' || + selectedSourceRef.type === 'mediaplayer' + ) { + // Action specifies what websocket method to call + const action = + selectedSourceRef.type === 'html' ? 'closeHtml' : 'closeMediaplayer'; + const inputSlot = productionSetup.sources.find( + (source) => source._id === selectedSourceRef._id + )?.input_slot; + + if (!inputSlot) return; + + closeWebsocket(action, inputSlot); + } + const updatedSetup = removeSetupItem(selectedSourceRef, productionSetup); + if (!updatedSetup) return; setProductionSetup(updatedSetup); putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { @@ -623,20 +638,17 @@ export default function ProductionConfiguration({ params }: PageProps) { } }} onBlur={() => updateConfigName(configurationName)} - disabled={isLocked} + disabled={locked} />
    - setIsLocked(!isLocked)} - /> +
    @@ -668,42 +680,23 @@ export default function ProductionConfiguration({ params }: PageProps) { inventoryVisible ? 'min-w-[35%] ml-2 mt-2 max-h-[89vh]' : '' }`} > -
    -
    - -
    -
    - - ) => { - setFilteredSources(new Map(filtered)); - }} - /> - -
    -
      - {getSourcesToDisplay(filteredSources)} - {addSourceModal && selectedSource && ( - - )} -
    -
    + setInventoryVisible(false)} + isDisabledFunc={isDisabledFunction} + /> + {addSourceModal && selectedSource && ( + + )}
    {removeSourceModal && selectedSourceRef && ( { setInventoryVisible(true); @@ -776,7 +768,7 @@ export default function ProductionConfiguration({ params }: PageProps) { (pipeline, i) => { return ( ({ @@ -792,7 +784,7 @@ export default function ProductionConfiguration({ params }: PageProps) { )} {productionSetup?.production_settings && ( ({ option: controlPanel.name, available: controlPanel.outgoing_connections?.length === 0 @@ -811,10 +803,7 @@ export default function ProductionConfiguration({ params }: PageProps) {
    {productionSetup && productionSetup.isActive && (
    - +
    )}
    diff --git a/src/components/button/MonitoringButton.tsx b/src/components/button/MonitoringButton.tsx index 375844c7..86483daa 100644 --- a/src/components/button/MonitoringButton.tsx +++ b/src/components/button/MonitoringButton.tsx @@ -4,23 +4,17 @@ import { useTranslate } from '../../i18n/useTranslate'; import { useMonitoringError } from '../../hooks/monitoring'; import { IconLoader } from '@tabler/icons-react'; import { IconAlertTriangleFilled } from '@tabler/icons-react'; - type MonitoringButtonProps = { id: string; - isLocked: boolean; }; -export const MonitoringButton = ({ id, isLocked }: MonitoringButtonProps) => { +export const MonitoringButton = ({ id }: MonitoringButtonProps) => { const t = useTranslate(); const [hasError, loading] = useMonitoringError(id); return ( void; - isLocked: boolean; -}; - -export function CreateProduction({ onClick, isLocked }: CreateProductionProps) { +export function CreateProduction() { const router = useRouter(); const postProduction = usePostProduction(); @@ -54,19 +49,14 @@ export function CreateProduction({ onClick, isLocked }: CreateProductionProps) { {t('production_configuration')}
    - + @@ -105,11 +95,7 @@ export function CreateProduction({ onClick, isLocked }: CreateProductionProps) {
    {children}
    diff --git a/src/components/homePageContent/HomePageContent.tsx b/src/components/homePageContent/HomePageContent.tsx index 170acd0f..8c358505 100644 --- a/src/components/homePageContent/HomePageContent.tsx +++ b/src/components/homePageContent/HomePageContent.tsx @@ -1,26 +1,24 @@ 'use client'; import { CreateProduction } from '../createProduction/CreateProduction'; -import { Suspense, useState } from 'react'; +import { Suspense, useContext } from 'react'; import { LoadingCover } from '../loader/LoadingCover'; import ProductionsList from '../productionsList/ProductionsList'; import { Production } from '../../interfaces/production'; +import { GlobalContext } from '../../contexts/GlobalContext'; type HomePageContentProps = { productions: Production[]; }; export const HomePageContent = ({ productions }: HomePageContentProps) => { - const [isLocked, setIsLocked] = useState(true); + const { locked } = useContext(GlobalContext); return (
    - setIsLocked(!isLocked)} - /> +
    }> - +
    diff --git a/src/components/image/ImageComponent.tsx b/src/components/image/ImageComponent.tsx new file mode 100644 index 00000000..df6acf82 --- /dev/null +++ b/src/components/image/ImageComponent.tsx @@ -0,0 +1,111 @@ +import { + PropsWithChildren, + SyntheticEvent, + useContext, + useEffect, + useRef, + useState +} from 'react'; +import Image from 'next/image'; +import { IconExclamationCircle } from '@tabler/icons-react'; +import { Loader } from '../loader/Loader'; +import { GlobalContext } from '../../contexts/GlobalContext'; +import { Type } from '../../interfaces/Source'; + +interface ImageComponentProps extends PropsWithChildren { + src?: string; + alt?: string; + type?: Type; +} + +const ImageComponent: React.FC = (props) => { + const { src, alt = 'Image', children, type } = props; + const { imageRefetchIndex } = useContext(GlobalContext); + const [imgSrc, setImgSrc] = useState(); + const [loaded, setLoaded] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState>(); + const timeout = useRef>(); + + const refetchImage = () => { + setImgSrc(`${src}?refetch=${imageRefetchIndex}}`); + setError(undefined); + setLoading(true); + clearTimeout(timeout.current); + timeout.current = setTimeout(() => setLoading(false), 500); + }; + + useEffect(() => { + refetchImage(); + }, [imageRefetchIndex]); + + useEffect(() => { + setError(undefined); + setImgSrc(`${src}?refetch=${imageRefetchIndex}}`); + }, [src]); + + useEffect(() => { + return () => { + clearTimeout(timeout.current); + }; + }, []); + + return ( + <> + {(!type || type === 'ingest_source') && src && ( +
    + {((!imgSrc || error) && ( + + )) || ( + <> + {alt} { + setError(undefined); + setLoaded(false); + }} + onLoadingComplete={() => { + setLoaded(true); + }} + onError={(err) => { + setError(err); + }} + placeholder="empty" + width={0} + height={0} + sizes="20vh" + style={{ + width: 'auto', + height: '100%' + }} + /> + + + )} + {children} +
    + )} + {(type === 'html' || type === 'mediaplayer') && ( + +

    + {type === 'html' ? 'HTML' : 'Media Player'} +

    +
    + )} + + ); +}; + +export default ImageComponent; diff --git a/src/components/inventory/Inventory.tsx b/src/components/inventory/Inventory.tsx index b2d5d846..e29fcdbc 100644 --- a/src/components/inventory/Inventory.tsx +++ b/src/components/inventory/Inventory.tsx @@ -3,28 +3,19 @@ import { useEffect, useState } from 'react'; import { useSources } from '../../hooks/sources/useSources'; import { useSetSourceToPurge } from '../../hooks/sources/useSetSourceToPurge'; -import FilterOptions from '../../components/filter/FilterOptions'; -import SourceListItem from '../../components/sourceListItem/SourceListItem'; import { SourceWithId } from '../../interfaces/Source'; import EditView from './editView/EditView'; -import FilterContext from './FilterContext'; -import styles from './Inventory.module.scss'; +import SourceList from '../sourceList/SourceList'; +import { useTranslate } from '../../i18n/useTranslate'; -type InventoryProps = { - isLocked: boolean; -}; - -export default function Inventory({ isLocked }: InventoryProps) { +export default function Inventory({ locked }: { locked: boolean }) { const [removeInventorySource, reloadList] = useSetSourceToPurge(); const [updatedSource, setUpdatedSource] = useState< SourceWithId | undefined >(); const [sources] = useSources(reloadList, updatedSource); const [currentSource, setCurrentSource] = useState(); - const [filteredSources, setFilteredSources] = - useState>(sources); - - const inventoryVisible = true; + const t = useTranslate(); useEffect(() => { if (updatedSource && typeof updatedSource !== 'boolean') { @@ -39,73 +30,25 @@ export default function Inventory({ isLocked }: InventoryProps) { }, [reloadList]); const editSource = (source: SourceWithId) => { - setCurrentSource(() => source); + setCurrentSource(source); }; - - function getSourcesToDisplay( - filteredSources: Map - ): React.ReactNode { - return Array.from(filteredSources.values()).map((source, index) => { - if (source.status !== 'purge') { - return ( - { - editSource(source); - }} - isLocked={isLocked} - /> - ); - } - }); - } - return ( - -
    -
    -
    -
    - ) => - setFilteredSources(new Map(filtered)) - } - /> -
    -
      - {getSourcesToDisplay(filteredSources)} -
    -
    +
    + + {currentSource ? ( +
    + setUpdatedSource(source)} + close={() => setCurrentSource(null)} + removeInventorySource={removeInventorySource} + />
    - - {currentSource ? ( -
    - setUpdatedSource(source)} - close={() => setCurrentSource(null)} - removeInventorySource={(source) => removeInventorySource(source)} - /> -
    - ) : null} -
    - + ) : null} +
    ); } diff --git a/src/components/inventory/InventoryPageContent.tsx b/src/components/inventory/InventoryPageContent.tsx index 06e5ee6a..a773ed63 100644 --- a/src/components/inventory/InventoryPageContent.tsx +++ b/src/components/inventory/InventoryPageContent.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useState, Suspense } from 'react'; +import { Suspense, useContext } from 'react'; import { LockButton } from '../lockButton/LockButton'; import { useTranslate } from '../../i18n/useTranslate'; import HeaderNavigation from '../headerNavigation/HeaderNavigation'; import Inventory from './Inventory'; +import { GlobalContext } from '../../contexts/GlobalContext'; export const InventoryPageContent = () => { - const [isLocked, setIsLocked] = useState(true); const t = useTranslate(); + const { locked } = useContext(GlobalContext); return ( <> @@ -17,15 +18,12 @@ export const InventoryPageContent = () => {

    {t('inventory')}

    - setIsLocked(!isLocked)} - /> +
    - + ); diff --git a/src/components/inventory/editView/AudioChannels/AudioChannels.tsx b/src/components/inventory/editView/AudioChannels/AudioChannels.tsx index 4315e645..7024fede 100644 --- a/src/components/inventory/editView/AudioChannels/AudioChannels.tsx +++ b/src/components/inventory/editView/AudioChannels/AudioChannels.tsx @@ -11,10 +11,10 @@ import { channel, mapAudio } from '../../../../utils/audioMapping'; interface IAudioChannels { source: Source; - isLocked: boolean; + locked: boolean; } -export default function AudioChannels({ source, isLocked }: IAudioChannels) { +export default function AudioChannels({ source, locked }: IAudioChannels) { type TOutputs = 'audio_mapping.outL' | 'audio_mapping.outR'; const t = useTranslate(); const outputs: TOutputs[] = ['audio_mapping.outL', 'audio_mapping.outR']; @@ -227,8 +227,8 @@ export default function AudioChannels({ source, isLocked }: IAudioChannels) { outputRows={outputRows} rowIndex={rowIndex} max={max} - isLocked={isLocked} updateRows={updateRows} + locked={locked} />
    ))} diff --git a/src/components/inventory/editView/AudioChannels/Outputs.tsx b/src/components/inventory/editView/AudioChannels/Outputs.tsx index f9dcf4b2..7bf3b0e7 100644 --- a/src/components/inventory/editView/AudioChannels/Outputs.tsx +++ b/src/components/inventory/editView/AudioChannels/Outputs.tsx @@ -18,7 +18,7 @@ interface IOutput { rowIndex: number; max: number; small?: boolean; - isLocked: boolean; + locked: boolean; updateRows?: (e: IEvent, rowIndex: number, index: number, id: string) => void; } @@ -28,7 +28,7 @@ export default function Outputs({ rowIndex, max, small = false, - isLocked, + locked, updateRows }: IOutput) { return ( @@ -52,7 +52,7 @@ export default function Outputs({ } relative ${styles.checkbox}`} > diff --git a/src/components/inventory/editView/EditView.tsx b/src/components/inventory/editView/EditView.tsx index 6587c1ac..ce312aff 100644 --- a/src/components/inventory/editView/EditView.tsx +++ b/src/components/inventory/editView/EditView.tsx @@ -1,66 +1,41 @@ -import Image from 'next/image'; import { getSourceThumbnail } from '../../../utils/source'; -import { useMemo, useState } from 'react'; import EditViewContext from '../EditViewContext'; import GeneralSettings from './GeneralSettings'; import { SourceWithId } from '../../../interfaces/Source'; import UpdateButtons from './UpdateButtons'; import AudioChannels from './AudioChannels/AudioChannels'; -import { IconExclamationCircle } from '@tabler/icons-react'; - -type EditViewProps = { - source: SourceWithId; - isLocked: boolean; - updateSource: (source: SourceWithId) => void; - close: () => void; - removeInventorySource: (source: SourceWithId) => void; -}; +import ImageComponent from '../../image/ImageComponent'; export default function EditView({ source, - isLocked, updateSource, close, - removeInventorySource -}: EditViewProps) { - const [loaded, setLoaded] = useState(false); - const src = useMemo(() => getSourceThumbnail(source), [source]); - + removeInventorySource, + locked +}: { + source: SourceWithId; + updateSource: (source: SourceWithId) => void; + close: () => void; + removeInventorySource: (source: SourceWithId) => void; + locked: boolean; +}) { return ( -
    - {source.status === 'gone' ? ( -
    - -
    - ) : ( - Preview Thumbnail setLoaded(true)} - placeholder="empty" - width={300} - height={0} - style={{ - objectFit: 'contain' - }} - /> - )} - - +
    +
    + +
    +
    - +
    ); diff --git a/src/components/inventory/editView/GeneralSettings.tsx b/src/components/inventory/editView/GeneralSettings.tsx index 42c137fe..852efa43 100644 --- a/src/components/inventory/editView/GeneralSettings.tsx +++ b/src/components/inventory/editView/GeneralSettings.tsx @@ -1,16 +1,16 @@ import { useContext } from 'react'; import { EditViewContext, IInput } from '../EditViewContext'; -import { FilterContext } from '../FilterContext'; +import { FilterContext } from '../../../contexts/FilterContext'; import { useTranslate } from '../../../i18n/useTranslate'; import SelectOptions from './SelectOptions'; import { getHertz } from '../../../utils/stream'; import videoSettings from '../../../utils/videoSettings'; type GeneralSettingsProps = { - isLocked: boolean; + locked: boolean; }; -export default function GeneralSettings({ isLocked }: GeneralSettingsProps) { +export default function GeneralSettings({ locked }: GeneralSettingsProps) { const { input: [input, setInput], saved: [saved, setSaved], @@ -46,11 +46,11 @@ export default function GeneralSettings({ isLocked }: GeneralSettingsProps) { value={input.name} onChange={(e) => onChange('name', e.target.value)} className={`${ - isLocked + locked ? 'pointer-events-none bg-gray-700/50 border-gray-600/50 placeholder-gray-400/50 text-p/50' : 'pointer-events-auto bg-gray-700 border-gray-600 placeholder-gray-400 text-p' } 'cursor-pointer ml-5 border justify-center text-sm rounded-lg w-full pl-2 pt-1 pb-1 focus:ring-blue-500 focus:border-blue-500'`} - disabled={isLocked} + disabled={locked} />
    @@ -59,7 +59,7 @@ export default function GeneralSettings({ isLocked }: GeneralSettingsProps) { name="type" options={types} selected={input.type} - disabled={isLocked} + disabled={locked} onChange={(e) => onChange('type', e.target.value.toLowerCase())} /> @@ -68,7 +68,7 @@ export default function GeneralSettings({ isLocked }: GeneralSettingsProps) { name="location" options={locations} selected={input.location} - disabled={isLocked} + disabled={locked} onChange={(e) => onChange('location', e.target.value.toLowerCase())} /> diff --git a/src/components/inventory/editView/UpdateButtons.tsx b/src/components/inventory/editView/UpdateButtons.tsx index 622f7602..a7be60dc 100644 --- a/src/components/inventory/editView/UpdateButtons.tsx +++ b/src/components/inventory/editView/UpdateButtons.tsx @@ -9,16 +9,16 @@ import { IconTrash } from '@tabler/icons-react'; type UpdateButtonsProps = { source: SourceWithId; - isLocked: boolean; removeInventorySource: (source: SourceWithId) => void; close: () => void; + locked: boolean; }; export default function UpdateButtons({ source, - isLocked, close, - removeInventorySource + removeInventorySource, + locked }: UpdateButtonsProps) { const t = useTranslate(); const { @@ -39,18 +39,16 @@ export default function UpdateButtons({ - + {source && !sourceRef && ( + + )} + {!source && sourceRef && } + {(source || sourceRef) && ( +

    + {t('source.input_slot', { + input_slot: + sourceRef?.input_slot?.toString() || + source?.input_slot?.toString() || + '' + })} +

    + )} + {source && ( +

    + {t('source.ingest', { + ingest: source.ingest_name + })} +

    + )} + {(source || sourceRef) && ( + + )} ); } diff --git a/src/components/sourceCard/SourceThumbnail.tsx b/src/components/sourceCard/SourceThumbnail.tsx deleted file mode 100644 index b5e5bcbe..00000000 --- a/src/components/sourceCard/SourceThumbnail.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import { useState } from 'react'; -import { Source, Type } from '../../interfaces/Source'; -import { IconExclamationCircle } from '@tabler/icons-react'; - -type SourceThumbnailProps = { - source?: Source; - src?: string; - type?: Type; -}; - -export function SourceThumbnail({ source, src, type }: SourceThumbnailProps) { - const [loaded, setLoaded] = useState(false); - - if (source && source.status === 'gone') { - return ( -
    - -
    - ); - } - - return ( - <> - {(type === 'ingest_source' || !type) && src && ( - Preview Thumbnail setLoaded(true)} - onError={() => setLoaded(true)} - placeholder="empty" - width={0} - height={0} - sizes="20vh" - style={{ - width: 'auto', - height: '100%' - }} - /> - )} - {(type === 'html' || type === 'mediaplayer') && ( - -

    - {type === 'html' ? 'HTML' : 'Media Player'} -

    -
    - )} - - ); -} diff --git a/src/components/sourceCards/SourceCards.tsx b/src/components/sourceCards/SourceCards.tsx index 0d4b2136..3ca7a2bf 100644 --- a/src/components/sourceCards/SourceCards.tsx +++ b/src/components/sourceCards/SourceCards.tsx @@ -6,18 +6,17 @@ import DragItem from '../dragElement/DragItem'; import SourceCard from '../sourceCard/SourceCard'; import { ISource, useDragableItems } from '../../hooks/useDragableItems'; import { EmptySlotCard } from '../emptySlotCard/EmptySlotCard'; + export default function SourceCards({ productionSetup, updateProduction, onSourceUpdate, - onSourceRemoval, - isLocked + onSourceRemoval }: { productionSetup: Production; updateProduction: (updated: Production) => void; onSourceUpdate: (source: SourceReference) => void; onSourceRemoval: (source: SourceReference) => void; - isLocked: boolean; }) { const [items, moveItem] = useDragableItems(productionSetup.sources); const [selectingText, setSelectingText] = useState(false); @@ -57,31 +56,28 @@ export default function SourceCards({ selectingText={selectingText} > - setSelectingText(isSelecting) - } - isLocked={isLocked} + onSelectingText={(isSelecting) => setSelectingText(isSelecting)} /> ); } else { gridItems.push( - setSelectingText(isSelecting) - } - isLocked={isLocked} + onSelectingText={(isSelecting) => setSelectingText(isSelecting)} /> ); } diff --git a/src/components/inventory/Inventory.module.scss b/src/components/sourceList/SourceList.module.scss similarity index 100% rename from src/components/inventory/Inventory.module.scss rename to src/components/sourceList/SourceList.module.scss diff --git a/src/components/sourceList/SourceList.tsx b/src/components/sourceList/SourceList.tsx new file mode 100644 index 00000000..b4b0c992 --- /dev/null +++ b/src/components/sourceList/SourceList.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useState } from 'react'; +import { SourceWithId } from '../../interfaces/Source'; +import FilterContext from '../../contexts/FilterContext'; +import SourceListItem from '../sourceListItem/SourceListItem'; +import FilterOptions from '../filter/FilterOptions'; +import styles from './SourceList.module.scss'; +import { IconX } from '@tabler/icons-react'; + +interface SourceListProps { + sources: Map; + inventoryVisible?: boolean; + onClose?: () => void; + isDisabledFunc?: (source: SourceWithId) => boolean; + action?: (source: SourceWithId) => void; + actionText?: string; +} + +const SourceList: React.FC = (props) => { + const { + sources, + inventoryVisible = true, + onClose, + isDisabledFunc, + action, + actionText, + locked + } = props; + + const [filteredSources, setFilteredSources] = + useState>(sources); + + function getSourcesToDisplay( + filteredSources: Map + ): React.ReactNode { + return Array.from( + filteredSources.size > 0 ? filteredSources.values() : sources.values() + ).map((source, index) => { + return ( + + ); + }); + } + + return ( + +
    +
    +
    +
    + ) => + setFilteredSources(new Map(filtered)) + } + /> + {onClose && ( + + )} +
    +
      + {getSourcesToDisplay(filteredSources)} +
    +
    +
    +
    +
    + ); +}; + +export default SourceList; diff --git a/src/components/sourceListItem/PreviewThumbnail.tsx b/src/components/sourceListItem/PreviewThumbnail.tsx deleted file mode 100644 index dcda882d..00000000 --- a/src/components/sourceListItem/PreviewThumbnail.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Image from 'next/image'; -import { useState } from 'react'; - -type PreviewProps = { src: string }; - -export const PreviewThumbnail = ({ src }: PreviewProps) => { - const [loaded, setLoaded] = useState(false); - - return ( -
    - Preview Thumbnail setLoaded(true)} - placeholder="empty" - width={0} - height={0} - sizes="20vh" - style={{ - width: 'auto', - height: '100%' - }} - /> -
    - ); -}; diff --git a/src/components/sourceListItem/SourceListItem.tsx b/src/components/sourceListItem/SourceListItem.tsx index 5a1521bd..55faacd7 100644 --- a/src/components/sourceListItem/SourceListItem.tsx +++ b/src/components/sourceListItem/SourceListItem.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Source, SourceReference, SourceWithId } from '../../interfaces/Source'; -import { PreviewThumbnail } from './PreviewThumbnail'; -import { getSourceThumbnail } from '../../utils/source'; +import { SourceWithId } from '../../interfaces/Source'; import videoSettings from '../../utils/videoSettings'; import { getHertz } from '../../utils/stream'; import { useTranslate } from '../../i18n/useTranslate'; @@ -11,55 +9,29 @@ import Outputs from '../inventory/editView/AudioChannels/Outputs'; import { mapAudio } from '../../utils/audioMapping'; import { oneBased } from '../inventory/editView/AudioChannels/utils'; import capitalize from '../../utils/capitalize'; +import { SourceListItemThumbnail } from './SourceListItemThumbnail'; type SourceListItemProps = { source: SourceWithId; - edit?: boolean; + action?: (source: SourceWithId) => void; + actionText?: string; disabled: unknown; - isLocked: boolean; - action: (source: SourceWithId) => void; + locked: boolean; }; -const getIcon = (source: Source) => { - const isGone = source.status === 'gone'; - const className = isGone ? 'text-error' : 'text-brand'; - - const types = { - camera: ( - - ), - microphone: ( - - ), - graphics: ( - - ) - }; - - return types[source.type]; -}; - -function InventoryListItem({ +function SourceListItem({ source, action, disabled, - edit = false, - isLocked + actionText }: SourceListItemProps) { const t = useTranslate(); + const [previewVisible, setPreviewVisible] = useState(false); const [outputRows, setOutputRows] = useState< { id: string; value: string }[][] >([]); + const timeoutRef = useRef(); const { video_stream: videoStream, audio_stream: audioStream } = source; @@ -104,15 +76,10 @@ function InventoryListItem({ className={`relative w-full items-center border-b border-gray-600 ${ disabled ? 'bg-unclickable-bg' : 'hover:bg-zinc-700' }`} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} > - {source.status !== 'gone' && - source.type === 'camera' && - previewVisible && }
    -
    {getIcon(source)}
    +
    ))} @@ -180,22 +147,29 @@ function InventoryListItem({
    - {!disabled ? ( -
    - - )} - {multiviews.length === index + 1 && ( - - )} -
    -
    - + o.pipelineIndex === i)} + addStream={addStream} + updateStream={updateStream} + updateStreams={updateStreams} + deleteStream={deleteStream} + /> ); })} - - clearInputs()} onSave={onSave} /> + {multiviews && + multiviews.length > 0 && + multiviews.map((singleItem, index) => { + return ( +
    +
    +
    + + setLayoutModalOpen(input) + } + lastItem={multiviews.length === index + 1} + multiview={singleItem} + handleUpdateMultiview={(input) => + handleUpdateMultiview(input, index) + } + portDuplicateError={ + portDuplicateIndexes.length > 0 + ? portDuplicateIndexes.includes(index) + : false + } + /> +
    1 + ? 'justify-between' + : 'justify-end' + }`} + > + {multiviews.length > 1 && ( + + )} + {multiviews.length === index + 1 && ( + + )} +
    +
    +
    + ); + })} + + )} + {!!layoutModalOpen && ( + + setNewMultiviewPreset(newLayout) + } + /> + )} + (layoutModalOpen ? closeLayoutModal() : clearInputs())} + onSave={() => (layoutModalOpen ? onUpdateLayoutPreset() : onSave())} + /> ); } diff --git a/src/components/modal/configureOutputModal/Input.tsx b/src/components/modal/configureOutputModal/Input.tsx index 25c09543..a2b01553 100644 --- a/src/components/modal/configureOutputModal/Input.tsx +++ b/src/components/modal/configureOutputModal/Input.tsx @@ -8,6 +8,7 @@ interface IInput { onKeyDown?: (e: KeyboardEvent) => void; size?: 'small' | 'large'; inputError?: boolean; + placeholder?: string; } export default function Input({ @@ -17,7 +18,8 @@ export default function Input({ type = 'text', onKeyDown, size = 'small', - inputError + inputError, + placeholder }: IInput) { const errorCss = 'border-red-500 focus:border-red-500 focus:outline'; @@ -34,6 +36,7 @@ export default function Input({ } pl-2 pt-1 pb-1 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:border-gray-400 focus:outline-none ${ inputError ? errorCss : '' }`} + placeholder={placeholder ? placeholder : ''} /> ); diff --git a/src/components/modal/configureOutputModal/MultiviewLayoutSettings.tsx b/src/components/modal/configureOutputModal/MultiviewLayoutSettings.tsx new file mode 100644 index 00000000..9a37c87b --- /dev/null +++ b/src/components/modal/configureOutputModal/MultiviewLayoutSettings.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from 'react'; +import { useMultiviewPresets } from '../../../hooks/multiviewPreset'; +import Options from './Options'; +import { MultiviewPreset } from '../../../interfaces/preset'; +import { useTranslate } from '../../../i18n/useTranslate'; +import { + MultiviewViewsWithId, + useSetupMultiviewLayout +} from '../../../hooks/useSetupMultiviewLayout'; +import { Production } from '../../../interfaces/production'; +import { useConfigureMultiviewLayout } from '../../../hooks/useConfigureMultiviewLayout'; +import { SourceReference } from '../../../interfaces/Source'; +import Input from './Input'; + +type ChangeLayout = { + defaultLabel?: string; + source?: SourceReference; + id: number; +}; + +export default function MultiviewLayoutSettings({ + // configMode sets the mode of the configuration to create or edit, not implemented yet + configMode, + production, + setNewMultiviewPreset +}: { + configMode: string; + production: Production | undefined; + setNewMultiviewPreset: (preset: MultiviewPreset | null) => void; +}) { + const [selectedMultiviewPreset, setSelectedMultiviewPreset] = + useState(null); + const [changedLayout, setChangedLayout] = useState(null); + const [newPresetName, setNewPresetName] = useState(null); + const [multiviewPresets, loading] = useMultiviewPresets(); + const { multiviewPresetLayout } = useSetupMultiviewLayout( + selectedMultiviewPreset + ); + const { multiviewLayout } = useConfigureMultiviewLayout( + selectedMultiviewPreset, + changedLayout?.defaultLabel, + changedLayout?.source, + changedLayout?.id, + configMode, + newPresetName + ); + const t = useTranslate(); + + const multiviewPresetNames = multiviewPresets?.map((preset) => preset.name) + ? multiviewPresets?.map((preset) => preset.name) + : []; + + useEffect(() => { + setNewPresetName(null); + }, [configMode]); + + useEffect(() => { + if (multiviewPresets && multiviewPresets[0]) { + setSelectedMultiviewPreset(multiviewPresets[0]); + } + }, [multiviewPresets]); + + useEffect(() => { + if (multiviewLayout) { + setSelectedMultiviewPreset(multiviewLayout); + setNewMultiviewPreset(multiviewLayout); + } else { + setSelectedMultiviewPreset(null); + setNewMultiviewPreset(null); + } + }, [multiviewLayout]); + + const handlePresetUpdate = (name: string) => { + const presetLayout = multiviewPresets?.find( + (singlePreset) => singlePreset.name === name + ); + setNewPresetName(name); + if (presetLayout) { + setSelectedMultiviewPreset(presetLayout); + } + }; + + const handleChange = (id: number | undefined, value: string) => { + if (production && id && multiviewPresets) { + // Remove 2 from id to remove id for Preview- and Program-view + // Add 1 to index to get the correct input_slot + const idFirstInputView = id - 2 + 1; + const defaultLabel = multiviewPresets[0].layout.views.find( + (item) => item.input_slot === idFirstInputView + )?.label; + production.sources.map((source) => { + if (value === '') { + setChangedLayout({ defaultLabel, id }); + } + if (source.label === value) { + setChangedLayout({ source, id }); + } + }); + } + }; + + const renderPresetModel = () => { + if (multiviewPresetLayout) { + return ( +
    + {multiviewPresetLayout.layout.views.map( + (singleView: MultiviewViewsWithId) => { + const { x, y, width, height, label, id } = singleView; + const previewView = singleView.input_slot === 1002; + const programView = singleView.input_slot === 1001; + + return ( +
    + {production && (previewView || programView) && ( +

    {label}

    + )} + {production && !previewView && !programView && ( + singleSource.label + )} + value={label ? label : ''} + update={(value) => handleChange(id, value)} + columnStyle + /> + )} +
    + ); + } + )} +
    + ); + } + }; + + return ( +
    + {renderPresetModel()} +
    + handlePresetUpdate(value)} + /> + handlePresetUpdate(value)} + placeholder={t('preset.new_preset_name')} + /> +
    +
    + ); +} diff --git a/src/components/modal/configureOutputModal/MultiviewSettings.tsx b/src/components/modal/configureOutputModal/MultiviewSettings.tsx index e7420053..b64f2bd2 100644 --- a/src/components/modal/configureOutputModal/MultiviewSettings.tsx +++ b/src/components/modal/configureOutputModal/MultiviewSettings.tsx @@ -6,17 +6,22 @@ import { MultiviewPreset } from '../../../interfaces/preset'; import Input from './Input'; import Options from './Options'; import toast from 'react-hot-toast'; +import { IconSettings } from '@tabler/icons-react'; type MultiviewSettingsProps = { + lastItem: boolean; multiview?: MultiviewSettings; handleUpdateMultiview: (multiview: MultiviewSettings) => void; portDuplicateError: boolean; + openConfigModal: (input: string) => void; }; export default function MultiviewSettingsConfig({ + lastItem, multiview, handleUpdateMultiview, - portDuplicateError + portDuplicateError, + openConfigModal }: MultiviewSettingsProps) { const t = useTranslate(); const [multiviewPresets, loading] = useMultiviewPresets(); @@ -24,6 +29,12 @@ export default function MultiviewSettingsConfig({ MultiviewPreset | undefined >(multiview); + // TODO: When possible to edit layout, uncomment the following code + // const [modalOpen, setModalOpen] = useState(false); + // const toggleConfigModal = () => { + // setModalOpen((state) => !state); + // }; + useEffect(() => { if (multiview) { setSelectedMultiviewPreset(multiview); @@ -142,16 +153,55 @@ export default function MultiviewSettingsConfig({ const multiviewOrPreset = multiview ? multiview : selectedMultiviewPreset; return ( -
    +

    {t('preset.multiview_output_settings')}

    - handleSetSelectedMultiviewPreset(value)} - /> +
    + handleSetSelectedMultiviewPreset(value)} + /> + {lastItem && ( + // TODO: When possible to edit layout, uncomment the following code and remove the button below + + // <> + // + // {modalOpen && ( + //
    + // + // + //
    + // )} + // + )} +
    void; + columnStyle?: boolean; } -export default function Options({ label, options, value, update }: IOPtions) { +export default function Options({ + label, + options, + value, + update, + columnStyle +}: IOptions) { + const t = useTranslate(); return ( -
    +
    { + setSelectedValue(e.target.value); + }} + /> + +
    +
    {productionSetup?.production_settings && diff --git a/src/components/inventory/Inventory.tsx b/src/components/inventory/Inventory.tsx index e29fcdbc..ab4420e0 100644 --- a/src/components/inventory/Inventory.tsx +++ b/src/components/inventory/Inventory.tsx @@ -38,10 +38,12 @@ export default function Inventory({ locked }: { locked: boolean }) { sources={sources} action={editSource} actionText={t('inventory_list.edit')} + locked={locked} /> {currentSource ? (
    setUpdatedSource(source)} close={() => setCurrentSource(null)} diff --git a/src/components/inventory/editView/EditView.tsx b/src/components/inventory/editView/EditView.tsx index ce312aff..376ca207 100644 --- a/src/components/inventory/editView/EditView.tsx +++ b/src/components/inventory/editView/EditView.tsx @@ -25,7 +25,7 @@ export default function EditView({
    - +
    diff --git a/src/components/sourceCard/SourceCard.tsx b/src/components/sourceCard/SourceCard.tsx index 8ece767e..2f1d54b4 100644 --- a/src/components/sourceCard/SourceCard.tsx +++ b/src/components/sourceCard/SourceCard.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { ChangeEvent, KeyboardEvent, useState } from 'react'; +import React, { ChangeEvent, KeyboardEvent, useContext, useState } from 'react'; import { IconTrash } from '@tabler/icons-react'; -import { SourceReference } from '../../interfaces/Source'; +import { SourceReference, Type } from '../../interfaces/Source'; import { useTranslate } from '../../i18n/useTranslate'; import { ISource } from '../../hooks/useDragableItems'; import ImageComponent from '../image/ImageComponent'; import { getSourceThumbnail } from '../../utils/source'; +import { GlobalContext } from '../../contexts/GlobalContext'; type SourceCardProps = { source?: ISource; @@ -15,7 +16,9 @@ type SourceCardProps = { onSelectingText: (bool: boolean) => void; forwardedRef?: React.LegacyRef; style?: object; - src: string; + src?: string; + sourceRef?: SourceReference; + type: Type; }; export default function SourceCard({ @@ -26,7 +29,9 @@ export default function SourceCard({ onSelectingText, forwardedRef, src, - style + style, + sourceRef, + type }: SourceCardProps) { const [sourceLabel, setSourceLabel] = useState( sourceRef?.label || source?.name diff --git a/src/components/sourceList/SourceList.tsx b/src/components/sourceList/SourceList.tsx index b4b0c992..bb84d0c5 100644 --- a/src/components/sourceList/SourceList.tsx +++ b/src/components/sourceList/SourceList.tsx @@ -15,6 +15,7 @@ interface SourceListProps { isDisabledFunc?: (source: SourceWithId) => boolean; action?: (source: SourceWithId) => void; actionText?: string; + locked: boolean; } const SourceList: React.FC = (props) => { @@ -44,6 +45,7 @@ const SourceList: React.FC = (props) => { source={source} disabled={isDisabledFunc?.(source)} action={action} + locked={locked} /> ); }); diff --git a/src/components/sourceListItem/SourceListItem.tsx b/src/components/sourceListItem/SourceListItem.tsx index 55faacd7..cc692b1b 100644 --- a/src/components/sourceListItem/SourceListItem.tsx +++ b/src/components/sourceListItem/SourceListItem.tsx @@ -23,6 +23,7 @@ function SourceListItem({ source, action, disabled, + locked, actionText }: SourceListItemProps) { const t = useTranslate(); @@ -158,28 +159,19 @@ function SourceListItem({ >
    (disabled || !action ? '' : action(source))} + disabled ? 'text-unclickable-text' : 'text-brand' + } text-xs`} > -
    - {actionText} -
    - - -
    - ) : null} + {actionText} +
    + + +
    From 6e7da3b13877ada7ffc39623f729b1b6679bf2ed Mon Sep 17 00:00:00 2001 From: Linda Malm <109201562+malmen237@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:12:23 +0200 Subject: [PATCH 08/82] feat: possible to add feedback-streams to multiview-layout (#21) --- src/api/ateliereLive/pipelines/pipelines.ts | 3 +- .../ConfigureOutputModal.tsx | 4 +- .../MultiviewLayoutSettings.tsx | 171 ------------------ .../MultiviewLayout.tsx | 61 +++++++ .../MultiviewLayoutSettings.tsx | 133 ++++++++++++++ .../MultiviewSettings.tsx | 41 ++++- .../modal/configureOutputModal/Options.tsx | 9 +- .../configureOutputModal/PipelineSettings.tsx | 4 +- .../configureOutputModal/StreamAccordion.tsx | 2 +- src/hooks/useConfigureMultiviewLayout.tsx | 20 +- src/hooks/useCreateInputArray.tsx | 47 +++++ src/hooks/useSetupMultiviewLayout.tsx | 2 +- src/interfaces/preset.ts | 1 + 13 files changed, 301 insertions(+), 197 deletions(-) delete mode 100644 src/components/modal/configureOutputModal/MultiviewLayoutSettings.tsx create mode 100644 src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayout.tsx create mode 100644 src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayoutSettings.tsx create mode 100644 src/hooks/useCreateInputArray.tsx diff --git a/src/api/ateliereLive/pipelines/pipelines.ts b/src/api/ateliereLive/pipelines/pipelines.ts index 32fc3090..e5dd9a8d 100644 --- a/src/api/ateliereLive/pipelines/pipelines.ts +++ b/src/api/ateliereLive/pipelines/pipelines.ts @@ -92,7 +92,8 @@ export async function getPipelines(): Promise< name: pipeline.name, uuid: pipeline.uuid, outputs: pipeline.outputs, - streams: pipeline.streams + streams: pipeline.streams, + feedback_streams: pipeline.feedback_streams }; }); } diff --git a/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx b/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx index 0e074423..53cbb2a0 100644 --- a/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx +++ b/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx @@ -9,10 +9,10 @@ import toast from 'react-hot-toast'; import { MultiviewSettings } from '../../../interfaces/multiview'; import MultiviewSettingsConfig from './MultiviewSettings'; import PipelineSettingsConfig from './PipelineSettings'; -import MultiviewLayoutSettings from './MultiviewLayoutSettings'; import { IconPlus, IconTrash } from '@tabler/icons-react'; import { Production } from '../../../interfaces/production'; import { usePutMultiviewPreset } from '../../../hooks/multiviewPreset'; +import MultiviewLayoutSettings from './MultiviewLayoutSettings/MultiviewLayoutSettings'; export interface OutputStream { name: string; @@ -314,9 +314,11 @@ export function ConfigureOutputModal({
    setLayoutModalOpen(input) } + newMultiviewPreset={newMultiviewPreset} lastItem={multiviews.length === index + 1} multiview={singleItem} handleUpdateMultiview={(input) => diff --git a/src/components/modal/configureOutputModal/MultiviewLayoutSettings.tsx b/src/components/modal/configureOutputModal/MultiviewLayoutSettings.tsx deleted file mode 100644 index 9a37c87b..00000000 --- a/src/components/modal/configureOutputModal/MultiviewLayoutSettings.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useMultiviewPresets } from '../../../hooks/multiviewPreset'; -import Options from './Options'; -import { MultiviewPreset } from '../../../interfaces/preset'; -import { useTranslate } from '../../../i18n/useTranslate'; -import { - MultiviewViewsWithId, - useSetupMultiviewLayout -} from '../../../hooks/useSetupMultiviewLayout'; -import { Production } from '../../../interfaces/production'; -import { useConfigureMultiviewLayout } from '../../../hooks/useConfigureMultiviewLayout'; -import { SourceReference } from '../../../interfaces/Source'; -import Input from './Input'; - -type ChangeLayout = { - defaultLabel?: string; - source?: SourceReference; - id: number; -}; - -export default function MultiviewLayoutSettings({ - // configMode sets the mode of the configuration to create or edit, not implemented yet - configMode, - production, - setNewMultiviewPreset -}: { - configMode: string; - production: Production | undefined; - setNewMultiviewPreset: (preset: MultiviewPreset | null) => void; -}) { - const [selectedMultiviewPreset, setSelectedMultiviewPreset] = - useState(null); - const [changedLayout, setChangedLayout] = useState(null); - const [newPresetName, setNewPresetName] = useState(null); - const [multiviewPresets, loading] = useMultiviewPresets(); - const { multiviewPresetLayout } = useSetupMultiviewLayout( - selectedMultiviewPreset - ); - const { multiviewLayout } = useConfigureMultiviewLayout( - selectedMultiviewPreset, - changedLayout?.defaultLabel, - changedLayout?.source, - changedLayout?.id, - configMode, - newPresetName - ); - const t = useTranslate(); - - const multiviewPresetNames = multiviewPresets?.map((preset) => preset.name) - ? multiviewPresets?.map((preset) => preset.name) - : []; - - useEffect(() => { - setNewPresetName(null); - }, [configMode]); - - useEffect(() => { - if (multiviewPresets && multiviewPresets[0]) { - setSelectedMultiviewPreset(multiviewPresets[0]); - } - }, [multiviewPresets]); - - useEffect(() => { - if (multiviewLayout) { - setSelectedMultiviewPreset(multiviewLayout); - setNewMultiviewPreset(multiviewLayout); - } else { - setSelectedMultiviewPreset(null); - setNewMultiviewPreset(null); - } - }, [multiviewLayout]); - - const handlePresetUpdate = (name: string) => { - const presetLayout = multiviewPresets?.find( - (singlePreset) => singlePreset.name === name - ); - setNewPresetName(name); - if (presetLayout) { - setSelectedMultiviewPreset(presetLayout); - } - }; - - const handleChange = (id: number | undefined, value: string) => { - if (production && id && multiviewPresets) { - // Remove 2 from id to remove id for Preview- and Program-view - // Add 1 to index to get the correct input_slot - const idFirstInputView = id - 2 + 1; - const defaultLabel = multiviewPresets[0].layout.views.find( - (item) => item.input_slot === idFirstInputView - )?.label; - production.sources.map((source) => { - if (value === '') { - setChangedLayout({ defaultLabel, id }); - } - if (source.label === value) { - setChangedLayout({ source, id }); - } - }); - } - }; - - const renderPresetModel = () => { - if (multiviewPresetLayout) { - return ( -
    - {multiviewPresetLayout.layout.views.map( - (singleView: MultiviewViewsWithId) => { - const { x, y, width, height, label, id } = singleView; - const previewView = singleView.input_slot === 1002; - const programView = singleView.input_slot === 1001; - - return ( -
    - {production && (previewView || programView) && ( -

    {label}

    - )} - {production && !previewView && !programView && ( - singleSource.label - )} - value={label ? label : ''} - update={(value) => handleChange(id, value)} - columnStyle - /> - )} -
    - ); - } - )} -
    - ); - } - }; - - return ( -
    - {renderPresetModel()} -
    - handlePresetUpdate(value)} - /> - handlePresetUpdate(value)} - placeholder={t('preset.new_preset_name')} - /> -
    -
    - ); -} diff --git a/src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayout.tsx b/src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayout.tsx new file mode 100644 index 00000000..36f866ec --- /dev/null +++ b/src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayout.tsx @@ -0,0 +1,61 @@ +import { TList } from '../../../../hooks/useCreateInputArray'; +import { MultiviewViewsWithId } from '../../../../hooks/useSetupMultiviewLayout'; +import { MultiviewPreset } from '../../../../interfaces/preset'; +import Options from '../Options'; + +export default function MultiviewLayout({ + multiviewPresetLayout, + inputList, + handleChange +}: { + multiviewPresetLayout: MultiviewPreset; + inputList: TList[] | undefined; + handleChange: (id: number, value: string) => void; +}) { + return ( +
    + {(multiviewPresetLayout.layout.views as MultiviewViewsWithId[]).map( + (singleView: MultiviewViewsWithId) => { + const { x, y, width, height, label, id } = singleView; + const previewView = singleView.input_slot === 1002 && y === 0; + const programView = singleView.input_slot === 1001 && y === 0; + + return ( +
    + {inputList && (previewView || programView) && ( +

    {label}

    + )} + {inputList && !previewView && !programView && ( + ({ + id: singleSource.id, + label: singleSource.label + }))} + value={label ? label : ''} + update={(value) => handleChange(id, value)} + columnStyle + /> + )} +
    + ); + } + )} +
    + ); +} diff --git a/src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayoutSettings.tsx b/src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayoutSettings.tsx new file mode 100644 index 00000000..b05ee4cd --- /dev/null +++ b/src/components/modal/configureOutputModal/MultiviewLayoutSettings/MultiviewLayoutSettings.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; +import { useMultiviewPresets } from '../../../../hooks/multiviewPreset'; +import Options from '../Options'; +import { MultiviewPreset } from '../../../../interfaces/preset'; +import { useTranslate } from '../../../../i18n/useTranslate'; +import { useSetupMultiviewLayout } from '../../../../hooks/useSetupMultiviewLayout'; +import { Production } from '../../../../interfaces/production'; +import { useConfigureMultiviewLayout } from '../../../../hooks/useConfigureMultiviewLayout'; +import Input from '../Input'; +import MultiviewLayout from './MultiviewLayout'; +import { + TList, + useCreateInputArray +} from '../../../../hooks/useCreateInputArray'; + +type ChangeLayout = { + defaultLabel?: string; + source?: TList; + viewId: number; +}; + +export default function MultiviewLayoutSettings({ + // TODO configMode sets the mode of the configuration to create or edit, not implemented yet + configMode, + production, + setNewMultiviewPreset +}: { + configMode: string; + production: Production | undefined; + setNewMultiviewPreset: (preset: MultiviewPreset | null) => void; +}) { + const [selectedMultiviewPreset, setSelectedMultiviewPreset] = + useState(null); + const [changedLayout, setChangedLayout] = useState(null); + const [newPresetName, setNewPresetName] = useState(null); + const [multiviewPresets, loading] = useMultiviewPresets(); + const { multiviewPresetLayout } = useSetupMultiviewLayout( + selectedMultiviewPreset + ); + const { multiviewLayout } = useConfigureMultiviewLayout( + selectedMultiviewPreset, + changedLayout?.defaultLabel, + changedLayout?.source, + changedLayout?.viewId, + configMode, + newPresetName + ); + const { inputList } = useCreateInputArray(production); + const t = useTranslate(); + + const multiviewPresetNames = multiviewPresets?.map((preset) => preset.name) + ? multiviewPresets?.map((preset) => preset.name) + : []; + + useEffect(() => { + setNewPresetName(null); + }, [configMode]); + + useEffect(() => { + if (multiviewPresets && multiviewPresets[0]) { + setSelectedMultiviewPreset(multiviewPresets[0]); + } + }, [multiviewPresets]); + + useEffect(() => { + if (multiviewLayout) { + setSelectedMultiviewPreset(multiviewLayout); + setNewMultiviewPreset(multiviewLayout); + } else { + setSelectedMultiviewPreset(null); + setNewMultiviewPreset(null); + } + }, [multiviewLayout]); + + const handlePresetUpdate = (name: string) => { + const presetLayout = multiviewPresets?.find( + (singlePreset) => singlePreset.name === name + ); + setNewPresetName(name); + if (presetLayout) { + setSelectedMultiviewPreset(presetLayout); + } + }; + + const handleChange = (viewId: number, value: string) => { + if (inputList && multiviewPresets) { + // Remove 2 from id to remove id for Preview- and Program-view + // Add 1 to index to get the correct input_slot + const idFirstInputView = viewId - 2 + 1; + const defaultLabel = multiviewPresets[0].layout.views.find( + (item) => item.input_slot === idFirstInputView + )?.label; + inputList.map((source) => { + if (value === '') { + setChangedLayout({ defaultLabel, viewId }); + } + if (source.id === value) { + setChangedLayout({ source, viewId }); + } + }); + } + }; + + return ( +
    + {multiviewPresetLayout && ( + + handleChange(viewId, value) + } + /> + )} +
    + ({ + label: singleItem + }))} + value={selectedMultiviewPreset ? selectedMultiviewPreset.name : ''} + update={(value) => handlePresetUpdate(value)} + /> + handlePresetUpdate(value)} + placeholder={t('preset.new_preset_name')} + /> +
    +
    + ); +} diff --git a/src/components/modal/configureOutputModal/MultiviewSettings.tsx b/src/components/modal/configureOutputModal/MultiviewSettings.tsx index b64f2bd2..9fab55b6 100644 --- a/src/components/modal/configureOutputModal/MultiviewSettings.tsx +++ b/src/components/modal/configureOutputModal/MultiviewSettings.tsx @@ -14,6 +14,8 @@ type MultiviewSettingsProps = { handleUpdateMultiview: (multiview: MultiviewSettings) => void; portDuplicateError: boolean; openConfigModal: (input: string) => void; + newMultiviewPreset: MultiviewPreset | null; + productionId: string | undefined; }; export default function MultiviewSettingsConfig({ @@ -21,10 +23,15 @@ export default function MultiviewSettingsConfig({ multiview, handleUpdateMultiview, portDuplicateError, - openConfigModal + openConfigModal, + newMultiviewPreset, + productionId }: MultiviewSettingsProps) { const t = useTranslate(); const [multiviewPresets, loading] = useMultiviewPresets(); + const [avaliableMultiviewPresets, setAvaliableMultiviewPresets] = useState< + MultiviewPreset[] | undefined + >(); const [selectedMultiviewPreset, setSelectedMultiviewPreset] = useState< MultiviewPreset | undefined >(multiview); @@ -36,14 +43,32 @@ export default function MultiviewSettingsConfig({ // }; useEffect(() => { + if (multiviewPresets) { + const globalPresets = multiviewPresets.filter((preset) => { + !preset.production_id; + }); + + const productionPresets = multiviewPresets.filter((preset) => { + preset.production_id?.toString() === productionId; + }); + + setAvaliableMultiviewPresets([...globalPresets, ...productionPresets]); + } + }, [multiviewPresets, productionId]); + + useEffect(() => { + if (newMultiviewPreset && lastItem) { + setSelectedMultiviewPreset(newMultiviewPreset); + return; + } if (multiview) { setSelectedMultiviewPreset(multiview); return; } - if (multiviewPresets && multiviewPresets[0]) { - setSelectedMultiviewPreset(multiviewPresets[0]); + if (avaliableMultiviewPresets && avaliableMultiviewPresets[0]) { + setSelectedMultiviewPreset(avaliableMultiviewPresets[0]); } - }, [multiviewPresets, multiview]); + }, [avaliableMultiviewPresets, multiview, newMultiviewPreset]); if (!multiview) { if (!multiviewPresets || multiviewPresets.length === 0) { @@ -160,7 +185,9 @@ export default function MultiviewSettingsConfig({
    ({ + label: singleItem + }))} value={selectedMultiviewPreset ? selectedMultiviewPreset.name : ''} update={(value) => handleSetSelectedMultiviewPreset(value)} /> @@ -205,7 +232,7 @@ export default function MultiviewSettingsConfig({
    {columnStyle && } {options.map((value, i) => ( - ))} diff --git a/src/components/modal/configureOutputModal/PipelineSettings.tsx b/src/components/modal/configureOutputModal/PipelineSettings.tsx index ef4c924b..dc93bbe3 100644 --- a/src/components/modal/configureOutputModal/PipelineSettings.tsx +++ b/src/components/modal/configureOutputModal/PipelineSettings.tsx @@ -90,14 +90,14 @@ export default function PipelineSettings({
    handleUpdateStream('videoFormat', value, streams[0].id) } /> diff --git a/src/components/modal/configureOutputModal/StreamAccordion.tsx b/src/components/modal/configureOutputModal/StreamAccordion.tsx index 325fba6b..1e231d41 100644 --- a/src/components/modal/configureOutputModal/StreamAccordion.tsx +++ b/src/components/modal/configureOutputModal/StreamAccordion.tsx @@ -58,7 +58,7 @@ export default function StreamAccordion({
    update('srtMode', value, stream.id)} /> diff --git a/src/hooks/useConfigureMultiviewLayout.tsx b/src/hooks/useConfigureMultiviewLayout.tsx index 768ba5ef..b356e9cb 100644 --- a/src/hooks/useConfigureMultiviewLayout.tsx +++ b/src/hooks/useConfigureMultiviewLayout.tsx @@ -1,13 +1,13 @@ -import { use, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { MultiviewPreset } from '../interfaces/preset'; -import { SourceReference } from '../interfaces/Source'; import { MultiviewViewsWithId } from './useSetupMultiviewLayout'; +import { TList } from './useCreateInputArray'; export function useConfigureMultiviewLayout( preset: MultiviewPreset | null, defaultLabel: string | undefined, - source: SourceReference | undefined, - id: number | undefined, + source: TList | undefined, + viewId: number | undefined, configMode: string, name: string | null ) { @@ -18,10 +18,10 @@ export function useConfigureMultiviewLayout( }, [configMode]); useEffect(() => { - if (preset && id && (defaultLabel || source)) { + if (preset && (defaultLabel || source)) { const arr: MultiviewViewsWithId[] = []; - preset.layout.views.map((item, index) => { - if (index === id) { + (preset.layout.views as MultiviewViewsWithId[]).map((item, index) => { + if (index === viewId) { if (source) { arr.push({ ...item, @@ -32,7 +32,7 @@ export function useConfigureMultiviewLayout( if (defaultLabel) { arr.push({ ...item, - input_slot: id, + input_slot: viewId, label: defaultLabel }); } @@ -53,10 +53,10 @@ export function useConfigureMultiviewLayout( }, [source?.input_slot, source?.label, defaultLabel]); useEffect(() => { - if (preset) { + if (preset && name && name !== preset.name) { return setUpdatedPreset({ ...preset, - name: name ? name : preset.name + name }); } }, [name]); diff --git a/src/hooks/useCreateInputArray.tsx b/src/hooks/useCreateInputArray.tsx new file mode 100644 index 00000000..60fd25db --- /dev/null +++ b/src/hooks/useCreateInputArray.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import { usePipelines } from './pipelines'; +import { Production } from '../interfaces/production'; + +export type TList = { + id: string; + input_slot: number; + label: string; +}; + +export function useCreateInputArray(production: Production | undefined) { + const [inputList, setInputList] = useState(); + const [pipelines] = usePipelines(); + + useEffect(() => { + if (production && pipelines) { + const list: TList[] = []; + production.sources.map((source) => + list.push({ + id: source._id ? source._id : '', + input_slot: source.input_slot, + label: source.label + }) + ); + pipelines.flatMap((pipeline) => + pipeline.feedback_streams.flatMap((source, index) => { + if (source.input_slot > 1000) { + list.push({ + id: (index + 1).toString(), + input_slot: source.input_slot, + label: source.name + }); + } + }) + ); + const uniqueList = list.filter( + (item, index, self) => + index === + self.findIndex( + (t) => t.input_slot === item.input_slot && t.label === item.label + ) + ); + return setInputList(uniqueList); + } + }, [production, pipelines]); + return { inputList }; +} diff --git a/src/hooks/useSetupMultiviewLayout.tsx b/src/hooks/useSetupMultiviewLayout.tsx index 66288b67..6183e1da 100644 --- a/src/hooks/useSetupMultiviewLayout.tsx +++ b/src/hooks/useSetupMultiviewLayout.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { MultiviewPreset } from '../interfaces/preset'; import { MultiviewViews } from '../interfaces/multiview'; -export type MultiviewViewsWithId = MultiviewViews & { id?: number }; +export type MultiviewViewsWithId = MultiviewViews & { id: number }; export function useSetupMultiviewLayout(preset: MultiviewPreset | null) { const [multiviewPreset, setMultiviewPreset] = useState(); diff --git a/src/interfaces/preset.ts b/src/interfaces/preset.ts index 2a51a721..814c5c20 100644 --- a/src/interfaces/preset.ts +++ b/src/interfaces/preset.ts @@ -20,6 +20,7 @@ export interface PresetReference { export interface MultiviewPreset { _id?: ObjectId; + production_id?: ObjectId; name: string; layout: MultiviewLayout; output: MultiviewOutputSettings; From 424e87023c5ad84736cf30e1706cfb1363c5fc36 Mon Sep 17 00:00:00 2001 From: Sandra Larsson Date: Thu, 26 Sep 2024 10:57:25 +0200 Subject: [PATCH 09/82] fix: solve bug error toast websockets (#22) * fix: solve bug error toast websockets * fix: if condition fix --- src/app/production/[id]/page.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/production/[id]/page.tsx b/src/app/production/[id]/page.tsx index 60596a54..e3a53de0 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -240,12 +240,18 @@ export default function ProductionConfiguration({ params }: PageProps) { }, []); useEffect(() => { - if (productionSetup && sources) { + if (productionSetup) { const hasMissingSource = productionSetup?.sources.find( - (productionSource) => - !Array.from(sources.values()).find( - (source) => source._id.toString() === productionSource._id - ) + (productionSource) => { + if ( + !['html', 'mediaplayer'].includes(productionSource.type) && + sources + ) { + !Array.from(sources.values()).find( + (source) => source._id.toString() === productionSource._id + ); + } + } ); if (hasMissingSource) { toast.error(t('error.missing_sources_in_db')); From 2994830a1034a865a87942fcf3f385e195101caf Mon Sep 17 00:00:00 2001 From: Sandra Larsson Date: Fri, 27 Sep 2024 12:30:15 +0200 Subject: [PATCH 10/82] fix: could remove sources from prod when locked (#23) --- src/components/sourceCard/SourceCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/sourceCard/SourceCard.tsx b/src/components/sourceCard/SourceCard.tsx index 2f1d54b4..1393fb26 100644 --- a/src/components/sourceCard/SourceCard.tsx +++ b/src/components/sourceCard/SourceCard.tsx @@ -120,6 +120,7 @@ export default function SourceCard({ )} {(source || sourceRef) && ( + {preset && ( + + )} + + ); +} diff --git a/src/components/modal/configureMultiviewModal/ConfigureMultiviewModal.tsx b/src/components/modal/configureMultiviewModal/ConfigureMultiviewModal.tsx new file mode 100644 index 00000000..5b38d8fc --- /dev/null +++ b/src/components/modal/configureMultiviewModal/ConfigureMultiviewModal.tsx @@ -0,0 +1,233 @@ +import { MultiviewPreset, Preset } from '../../../interfaces/preset'; +import { Modal } from '../Modal'; +import { useEffect, useState } from 'react'; +import { useTranslate } from '../../../i18n/useTranslate'; +import toast from 'react-hot-toast'; +import { MultiviewSettings } from '../../../interfaces/multiview'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; +import { Production } from '../../../interfaces/production'; +import { usePutMultiviewPreset } from '../../../hooks/multiviewPreset'; +import deepclone from 'lodash.clonedeep'; +import MultiviewSettingsConfig from '../configureOutputModal/MultiviewSettings'; +import MultiviewLayoutSettings from '../configureOutputModal/MultiviewLayoutSettings/MultiviewLayoutSettings'; +import Decision from '../configureOutputModal/Decision'; + +type ConfigureMultiviewModalProps = { + open: boolean; + preset: Preset; + onClose: () => void; + updatePreset: (preset: Preset) => void; + production?: Production; +}; + +export function ConfigureMultiviewModal({ + open, + preset, + onClose, + updatePreset, + production +}: ConfigureMultiviewModalProps) { + const [multiviews, setMultiviews] = useState([]); + const [portDuplicateIndexes, setPortDuplicateIndexes] = useState( + [] + ); + const [layoutModalOpen, setLayoutModalOpen] = useState(null); + const [newMultiviewPreset, setNewMultiviewPreset] = + useState(null); + const addNewPreset = usePutMultiviewPreset(); + const t = useTranslate(); + + useEffect(() => { + if (preset.pipelines[0].multiviews) { + if (!Array.isArray(preset.pipelines[0].multiviews)) { + setMultiviews([preset.pipelines[0].multiviews]); + } else { + setMultiviews(preset.pipelines[0].multiviews); + } + } + }, [preset.pipelines]); + + const clearInputs = () => { + setLayoutModalOpen(null); + setMultiviews(preset.pipelines[0].multiviews || []); + onClose(); + }; + + useEffect(() => { + runDuplicateCheck(multiviews); + }, [multiviews]); + + const onSave = () => { + const presetToUpdate = deepclone(preset); + + if (!multiviews) { + toast.error(t('preset.no_multiview_selected')); + return; + } + + if (portDuplicateIndexes.length > 0) { + toast.error(t('preset.no_port_selected')); + return; + } + + presetToUpdate.pipelines[0].multiviews = multiviews.map( + (singleMultiview) => { + return { ...singleMultiview }; + } + ); + + updatePreset(presetToUpdate); + onClose(); + }; + + const onUpdateLayoutPreset = () => { + if (!newMultiviewPreset) { + toast.error(t('preset.no_updated_layout')); + return; + } + addNewPreset(newMultiviewPreset); + setLayoutModalOpen(null); + }; + + const closeLayoutModal = () => { + setLayoutModalOpen(null); + }; + + const findDuplicateValues = (mvs: MultiviewSettings[]) => { + const ports = mvs.map( + (item: MultiviewSettings) => + item.output.local_ip + ':' + item.output.local_port.toString() + ); + const duplicateIndices: number[] = []; + const seenPorts = new Set(); + + ports.forEach((port, index) => { + if (seenPorts.has(port)) { + duplicateIndices.push(index); + // Also include the first occurrence if it's not already included + const firstIndex = ports.indexOf(port); + if (!duplicateIndices.includes(firstIndex)) { + duplicateIndices.push(firstIndex); + } + } else { + seenPorts.add(port); + } + }); + + return duplicateIndices; + }; + + const runDuplicateCheck = (mvs: MultiviewSettings[]) => { + const hasDuplicates = findDuplicateValues(mvs); + + if (hasDuplicates.length > 0) { + setPortDuplicateIndexes(hasDuplicates); + } + + if (hasDuplicates.length === 0) { + setPortDuplicateIndexes([]); + } + }; + + const handleUpdateMultiview = ( + multiview: MultiviewSettings, + index: number + ) => { + const updatedMultiviews = multiviews.map((item, i) => + i === index ? { ...item, ...multiview } : item + ); + + runDuplicateCheck(multiviews); + + setMultiviews(updatedMultiviews); + }; + + const addNewMultiview = (newMultiview: MultiviewSettings) => { + setMultiviews((prevMultiviews) => + prevMultiviews ? [...prevMultiviews, newMultiview] : [newMultiview] + ); + }; + + const removeNewMultiview = (index: number) => { + const newMultiviews = multiviews.filter((_, i) => i !== index); + setMultiviews(newMultiviews); + }; + + return ( + clearInputs()}> + {!layoutModalOpen && ( +
    + {multiviews && + multiviews.length > 0 && + multiviews.map((singleItem, index) => { + return ( +
    +
    +
    + + setLayoutModalOpen(input) + } + newMultiviewPreset={newMultiviewPreset} + lastItem={multiviews.length === index + 1} + multiview={singleItem} + handleUpdateMultiview={(input) => + handleUpdateMultiview(input, index) + } + portDuplicateError={ + portDuplicateIndexes.length > 0 + ? portDuplicateIndexes.includes(index) + : false + } + /> +
    1 + ? 'justify-between' + : 'justify-end' + }`} + > + {multiviews.length > 1 && ( + + )} + {multiviews.length === index + 1 && ( + + )} +
    +
    +
    + ); + })} +
    + )} + {!!layoutModalOpen && ( + + setNewMultiviewPreset(newLayout) + } + /> + )} + (layoutModalOpen ? closeLayoutModal() : clearInputs())} + onSave={() => (layoutModalOpen ? onUpdateLayoutPreset() : onSave())} + /> +
    + ); +} diff --git a/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx b/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx index 53cbb2a0..93d8ece6 100644 --- a/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx +++ b/src/components/modal/configureOutputModal/ConfigureOutputModal.tsx @@ -1,18 +1,18 @@ -import { MultiviewPreset, Preset } from '../../../interfaces/preset'; -import { ProgramOutput, PipelineSettings } from '../../../interfaces/pipeline'; +import { Preset } from '../../../interfaces/preset'; import { Modal } from '../Modal'; import Decision from './Decision'; -import { useEffect, useState } from 'react'; -import { useTranslate } from '../../../i18n/useTranslate'; -import { v4 as uuidv4 } from 'uuid'; -import toast from 'react-hot-toast'; -import { MultiviewSettings } from '../../../interfaces/multiview'; -import MultiviewSettingsConfig from './MultiviewSettings'; -import PipelineSettingsConfig from './PipelineSettings'; -import { IconPlus, IconTrash } from '@tabler/icons-react'; -import { Production } from '../../../interfaces/production'; -import { usePutMultiviewPreset } from '../../../hooks/multiviewPreset'; -import MultiviewLayoutSettings from './MultiviewLayoutSettings/MultiviewLayoutSettings'; +import PipelineOutputConfig, { PipelineTypes } from './PipelineOutputConfig'; +import { useState } from 'react'; +import { PipelineOutput, PipelineSettings } from '../../../interfaces/pipeline'; +import { usePipelines } from '../../../hooks/pipelines'; +import cloneDeep from 'lodash.clonedeep'; + +type ConfigureOutputModalProps = { + open: boolean; + preset: Preset; + onClose: () => void; + updatePreset: (preset: Preset) => void; +}; export interface OutputStream { name: string; @@ -27,356 +27,97 @@ export interface OutputStream { videoKiloBit: number; } -type ConfigureOutputModalProps = { - open: boolean; - preset: Preset; - onClose: () => void; - updatePreset: (preset: Preset) => void; - production: Production | undefined; -}; +const DEFAULT_PORT_MUMBER = 9900; export function ConfigureOutputModal({ open, preset, onClose, - updatePreset, - production + updatePreset }: ConfigureOutputModalProps) { - const defaultState = (pipelines: PipelineSettings[]) => { - const streamsPerPipe = pipelines.map((pipe, i) => { - return pipe.program_output.map((output) => ({ - name: ``, - id: uuidv4(), - pipelineIndex: i, - ip: - output?.srt_mode === 'listener' - ? output.local_ip - : output?.remote_ip || '0.0.0.0', - srtMode: output?.srt_mode, - srtPassphrase: output?.srt_passphrase || '', - port: output?.port, - videoFormat: output?.video_format, - videoBit: output?.video_bit_depth, - videoKiloBit: output?.video_kilobit_rate - })); - }); - - return streamsPerPipe.flatMap((streams) => { - return streams.map((stream, i) => { - return { ...stream, name: `Stream ${i + 1}` }; - }); - }) satisfies OutputStream[]; - }; - - const [outputstreams, setOutputStreams] = useState( - defaultState(preset.pipelines) + const [pipelines, setPipelines] = useState( + preset.pipelines || [] ); - const [multiviews, setMultiviews] = useState([]); - const [portDuplicateIndexes, setPortDuplicateIndexes] = useState( - [] - ); - const [layoutModalOpen, setLayoutModalOpen] = useState(null); - const [newMultiviewPreset, setNewMultiviewPreset] = - useState(null); - const addNewPreset = usePutMultiviewPreset(); - const t = useTranslate(); - - useEffect(() => { - if (preset.pipelines[0].multiviews) { - if (!Array.isArray(preset.pipelines[0].multiviews)) { - setMultiviews([preset.pipelines[0].multiviews]); - } else { - setMultiviews(preset.pipelines[0].multiviews); - } - } - }, [preset.pipelines]); - - useEffect(() => { - setOutputStreams(defaultState(preset.pipelines)); - }, [preset]); - - const clearInputs = () => { - setLayoutModalOpen(null); - setMultiviews(preset.pipelines[0].multiviews || []); - setOutputStreams(defaultState(preset.pipelines)); - onClose(); - }; + const [currentError, setCurrentError] = useState(''); + const [currentPortNumber, setCurrentPortNumber] = + useState(DEFAULT_PORT_MUMBER); - useEffect(() => { - runDuplicateCheck(multiviews); - }, [multiviews]); + const [pipes] = usePipelines(); const onSave = () => { - const presetToUpdate = { - ...preset, - pipelines: preset.pipelines.map((pipeline, i) => { - return { - ...pipeline, - program_output: streamsToProgramOutputs( - i, - outputstreams.filter((o) => o.pipelineIndex === i) - ) - }; - }) - }; - - if (!multiviews) { - toast.error(t('preset.no_multiview_selected')); - return; + const locations = pipelines + .map((p) => + p.outputs?.map((o) => + o.streams.map((s) => `${s.local_ip}:${s.local_port}`) + ) + ) + .flat(2); + function findDuplicates(array: any[]) { + return array.filter( + (currentValue, currentIndex) => + array.indexOf(currentValue) !== currentIndex + ); } - - if (portDuplicateIndexes.length > 0) { - toast.error(t('preset.no_port_selected')); + const duplicates = findDuplicates(locations); + if (duplicates.length) { + setCurrentError('Same : used for multiple streams'); return; } - - presetToUpdate.pipelines[0].multiviews = multiviews.map( - (singleMultiview) => { - return { ...singleMultiview }; - } - ); - - updatePreset(presetToUpdate); + updatePreset({ ...preset, pipelines: pipelines }); onClose(); }; - const onUpdateLayoutPreset = () => { - if (!newMultiviewPreset) { - toast.error(t('preset.no_updated_layout')); - return; - } - addNewPreset(newMultiviewPreset); - setLayoutModalOpen(null); - }; - - const closeLayoutModal = () => { - setLayoutModalOpen(null); - }; - - const streamsToProgramOutputs = ( - pipelineIndex: number, - outputStreams?: OutputStream[] + const updatePipelineOutputFunc = ( + pipeline: PipelineSettings, + outputs: PipelineOutput[] ) => { - if (!outputStreams) return []; - return outputStreams.map((stream) => ({ - ...preset.pipelines[pipelineIndex].program_output[0], - port: stream.port, - [stream.srtMode === 'listener' ? 'local_ip' : 'remote_ip']: stream.ip, - srt_mode: stream.srtMode, - video_bit_depth: stream.videoBit, - video_format: stream.videoFormat, - video_kilobit_rate: stream.videoKiloBit, - srt_passphrase: stream.srtPassphrase - })) satisfies ProgramOutput[]; - }; - - const addStream = (stream: OutputStream) => { - const streams = outputstreams.filter( - (o) => o.pipelineIndex === stream.pipelineIndex - ); - if (streams.length > 4) return; - setOutputStreams([ - ...outputstreams, - { - ...stream, - name: `${t('preset.stream_name')} ${streams.length + 1}`, - port: streams[streams.length - 1].port + 1 - } - ]); - }; - - const updateStream = (updatedStream: OutputStream) => { - setOutputStreams( - [ - ...outputstreams.filter((o) => o.id !== updatedStream.id), - updatedStream - ].sort((a, b) => a.name.localeCompare(b.name)) - ); - }; - - const updateStreams = (updatedStreams: OutputStream[]) => { - const streams = outputstreams.filter( - (o) => !updatedStreams.some((u) => u.id === o.id) - ); - setOutputStreams( - [...streams, ...updatedStreams].sort((a, b) => - a.name.localeCompare(b.name) - ) - ); - }; - - const setNames = (outputstreams: OutputStream[], index: number) => { - const streamsForPipe = outputstreams.filter( - (o) => o.pipelineIndex === index - ); - const rest = outputstreams.filter((o) => o.pipelineIndex !== index); - return [ - ...streamsForPipe.map((s, i) => ({ ...s, name: `Stream ${i + 1}` })), - ...rest - ]; - }; - - const deleteStream = (id: string, index: number) => { - setOutputStreams( - setNames( - outputstreams.filter((o) => o.id !== id), - index - ) - ); - }; - - const findDuplicateValues = (mvs: MultiviewSettings[]) => { - const ports = mvs.map( - (item: MultiviewSettings) => - item.output.local_ip + ':' + item.output.local_port.toString() + setCurrentError(''); + const pipelineIndex = pipelines.findIndex( + (p) => p.pipeline_name === pipeline.pipeline_name ); - const duplicateIndices: number[] = []; - const seenPorts = new Set(); - - ports.forEach((port, index) => { - if (seenPorts.has(port)) { - duplicateIndices.push(index); - // Also include the first occurrence if it's not already included - const firstIndex = ports.indexOf(port); - if (!duplicateIndices.includes(firstIndex)) { - duplicateIndices.push(firstIndex); - } - } else { - seenPorts.add(port); - } - }); - - return duplicateIndices; - }; - - const runDuplicateCheck = (mvs: MultiviewSettings[]) => { - const hasDuplicates = findDuplicateValues(mvs); - - if (hasDuplicates.length > 0) { - setPortDuplicateIndexes(hasDuplicates); - } - - if (hasDuplicates.length === 0) { - setPortDuplicateIndexes([]); + if (pipelineIndex >= 0) { + const newPipelines = cloneDeep(pipelines); + newPipelines.splice(pipelineIndex, 1, { ...pipeline, outputs: outputs }); + setPipelines(newPipelines); } }; - const handleUpdateMultiview = ( - multiview: MultiviewSettings, - index: number - ) => { - const updatedMultiviews = multiviews.map((item, i) => - i === index ? { ...item, ...multiview } : item - ); - - runDuplicateCheck(multiviews); - - setMultiviews(updatedMultiviews); - }; - - const addNewMultiview = (newMultiview: MultiviewSettings) => { - setMultiviews((prevMultiviews) => - prevMultiviews ? [...prevMultiviews, newMultiview] : [newMultiview] - ); - }; - - const removeNewMultiview = (index: number) => { - const newMultiviews = multiviews.filter((_, i) => i !== index); - setMultiviews(newMultiviews); + const getPortNumber = () => { + setCurrentPortNumber(currentPortNumber + 1); + return currentPortNumber; }; return ( - clearInputs()}> - {!layoutModalOpen && ( -
    - {preset.pipelines.map((pipeline, i) => { + { + onClose(); + }} + > +
    +
    + {pipelines.map((pipeline, i) => { return ( - o.pipelineIndex === i)} - addStream={addStream} - updateStream={updateStream} - updateStreams={updateStreams} - deleteStream={deleteStream} + p.name === pipeline.pipeline_name) + ?.outputs || [] + } + pipelineOutputs={pipeline.outputs || []} + updatePipelineOutputs={(outputs: PipelineOutput[]) => + updatePipelineOutputFunc(pipeline, outputs) + } + pipeline={pipeline} + getPortNumber={getPortNumber} /> ); })} - {multiviews && - multiviews.length > 0 && - multiviews.map((singleItem, index) => { - return ( -
    -
    -
    - - setLayoutModalOpen(input) - } - newMultiviewPreset={newMultiviewPreset} - lastItem={multiviews.length === index + 1} - multiview={singleItem} - handleUpdateMultiview={(input) => - handleUpdateMultiview(input, index) - } - portDuplicateError={ - portDuplicateIndexes.length > 0 - ? portDuplicateIndexes.includes(index) - : false - } - /> -
    1 - ? 'justify-between' - : 'justify-end' - }`} - > - {multiviews.length > 1 && ( - - )} - {multiviews.length === index + 1 && ( - - )} -
    -
    -
    - ); - })}
    - )} - {!!layoutModalOpen && ( - - setNewMultiviewPreset(newLayout) - } - /> - )} - (layoutModalOpen ? closeLayoutModal() : clearInputs())} - onSave={() => (layoutModalOpen ? onUpdateLayoutPreset() : onSave())} - /> +
    {currentError}
    + onClose()} onSave={onSave} /> +
    ); } diff --git a/src/components/modal/configureOutputModal/PipelineOutputConfig.tsx b/src/components/modal/configureOutputModal/PipelineOutputConfig.tsx new file mode 100644 index 00000000..4e590354 --- /dev/null +++ b/src/components/modal/configureOutputModal/PipelineOutputConfig.tsx @@ -0,0 +1,282 @@ +import { useEffect, useState, KeyboardEvent } from 'react'; +import { ResourcesNameAndUUIDResponse } from '../../../../types/ateliere-live'; +import { useTranslate } from '../../../i18n/useTranslate'; +import Input from './Input'; +import { + PipelineOutput, + PipelineOutputEncoderSettings, + PipelineOutputSettings, + PipelineSettings +} from '../../../interfaces/pipeline'; +import StreamAccordion from './StreamAccordion'; +import { OutputStream } from './ConfigureOutputModal'; +import Options from './Options'; +import cloneDeep from 'lodash.clonedeep'; + +export type PipelineTypes = 'LD' | 'HQ'; + +interface PipelineOutputConfigProps { + title: string; + outputs: ResourcesNameAndUUIDResponse[]; + pipelineOutputs: PipelineOutput[]; + updatePipelineOutputs: (outputs: PipelineOutput[]) => void; + getPortNumber: () => number; + pipeline: PipelineSettings; +} + +const getStreamEncoderSettings = (pipeline: PipelineSettings) => { + return { + video_bit_depth: pipeline.bit_depth, + video_format: pipeline.format, + video_kilobit_rate: pipeline.video_kilobit_rate + }; +}; + +const createNewStream = (portNumber: number, pipeline: PipelineSettings) => { + return { + audio_format: 'ADTS', + audio_kilobit_rate: 128, + format: 'MPEG-TS-SRT', + local_ip: '0.0.0.0', + local_port: portNumber, + remote_ip: '0.0.0.0', + remote_port: portNumber, + srt_latency_ms: 120, + srt_mode: 'listener', + srt_passphrase: '', + video_gop_length: 50, + ...getStreamEncoderSettings(pipeline) + }; +}; + +const PipelineOutputConfig: React.FC = (props) => { + const { + title, + outputs, + pipelineOutputs, + updatePipelineOutputs, + pipeline, + getPortNumber + } = props; + const [updatedOutputs, setUpdatedOutputs] = + useState(pipelineOutputs); + const t = useTranslate(); + + useEffect(() => { + updatePipelineOutputs(updatedOutputs); + }, [updatedOutputs]); + + const handleAddStream = (outputId: string) => { + const newOutputs: PipelineOutput[] = cloneDeep(updatedOutputs); + const foundOutput = newOutputs.find((o) => o.uuid === outputId); + const newStream = createNewStream(getPortNumber(), pipeline); + if (foundOutput) { + foundOutput?.streams.push(newStream); + } else { + newOutputs.push({ + uuid: outputId, + settings: getStreamEncoderSettings(pipeline), + streams: [newStream] + }); + } + setUpdatedOutputs(newOutputs); + }; + + const handleUpdateStream = ( + outputId: string, + index: number, + field: string, + value: string + ) => { + const foundOutputIndex = updatedOutputs.findIndex( + (o) => o.uuid === outputId + ); + if (foundOutputIndex >= 0) { + const getInt = (val: string) => { + if (Number.isNaN(parseInt(value))) { + return 0; + } + return parseInt(val); + }; + const newOutputs: PipelineOutput[] = cloneDeep(updatedOutputs); + const newStream = newOutputs[foundOutputIndex].streams[index]; + switch (field) { + default: + case 'port': + newStream.local_port = getInt(value); + newStream.remote_port = getInt(value); + break; + case 'srtMode': + newStream.srt_mode = value; + break; + case 'ip': + newStream.local_ip = value; + break; + case 'srtPassphrase': + newStream.srt_passphrase = value; + break; + } + setUpdatedOutputs(newOutputs); + } + }; + + const handleDeleteStream = (outputId: string, index: number) => { + const foundOutputIndex = updatedOutputs.findIndex( + (o) => o.uuid === outputId + ); + if (foundOutputIndex >= 0) { + const newOutputs = cloneDeep(updatedOutputs); + newOutputs[foundOutputIndex].streams.splice(index, 1); + setUpdatedOutputs(newOutputs); + } + }; + + const getOutputStreams = (outputId: string) => { + const outputStreams = + updatedOutputs.find((o) => o.uuid === outputId)?.streams || []; + if (!outputStreams.length) return; + + const convertStream = ( + stream: PipelineOutputSettings, + index: number + ): OutputStream => { + return { + name: `Stream ${index + 1}`, + id: '', + pipelineIndex: 0, + ip: stream.local_ip, + srtMode: stream.srt_mode, + srtPassphrase: stream.srt_passphrase, + port: stream.local_port, + videoFormat: stream.video_format, + videoBit: stream.video_bit_depth, + videoKiloBit: stream.video_kilobit_rate + }; + }; + return outputStreams.map((stream, index) => { + return ( + + handleUpdateStream(outputId, index, field, value) + } + onDelete={() => handleDeleteStream(outputId, index)} + /> + ); + }); + }; + + const preventCharacters = (evt: KeyboardEvent) => { + ['e', 'E', '+', '-'].includes(evt.key) && evt.preventDefault(); + }; + + const handleUpdateOutputSetting = ( + key: keyof PipelineOutputEncoderSettings, + value: string | number, + outputId: string + ) => { + const newOutputs: PipelineOutput[] = cloneDeep(updatedOutputs); + let foundOutputIndex = newOutputs.findIndex((o) => o.uuid === outputId); + if (foundOutputIndex < 0) { + newOutputs.push({ + uuid: outputId, + settings: getStreamEncoderSettings(pipeline), + streams: [] + }); + foundOutputIndex = newOutputs.findIndex((o) => o.uuid === outputId); + } + + if (!newOutputs[foundOutputIndex].settings) { + newOutputs[foundOutputIndex].settings = + getStreamEncoderSettings(pipeline); + } + newOutputs[foundOutputIndex].settings[key] = value as never; + setUpdatedOutputs(newOutputs); + }; + + const getOutputFields = (outputId: string) => { + const foundOutput = updatedOutputs.find((p) => p.uuid === outputId); + + return ( +
    + + handleUpdateOutputSetting('video_format', value, outputId) + } + /> + + handleUpdateOutputSetting('video_bit_depth', value, outputId) + } + /> + + + handleUpdateOutputSetting('video_kilobit_rate', value, outputId) + } + /> +
    + ); + }; + + return ( +
    +

    {title}

    +
    + {outputs?.map((output, index) => ( +
    +

    {output.name}

    + {getOutputFields(output.uuid)} +
    + {getOutputStreams(output.uuid)} +
    + +
    + ))} +
    +
    + ); +}; + +export default PipelineOutputConfig; diff --git a/src/components/modal/configureOutputModal/PipelineSettings.tsx b/src/components/modal/configureOutputModal/PipelineSettings.tsx deleted file mode 100644 index dc93bbe3..00000000 --- a/src/components/modal/configureOutputModal/PipelineSettings.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useTranslate } from '../../../i18n/useTranslate'; -import Input from './Input'; -import Options from './Options'; -import { KeyboardEvent } from 'react'; -import StreamAccordion from './StreamAccordion'; -import { OutputStream } from './ConfigureOutputModal'; -import { v4 as uuidv4 } from 'uuid'; - -interface PipelineSettingsProps { - title: string; - streams?: OutputStream[]; - addStream: (stream: OutputStream) => void; - updateStream: (stream: OutputStream) => void; - updateStreams: (streams: OutputStream[]) => void; - deleteStream: (id: string, index: number) => void; -} -export default function PipelineSettings({ - title, - streams, - addStream, - updateStream, - updateStreams, - deleteStream -}: PipelineSettingsProps) { - const t = useTranslate(); - - const preventCharachters = (evt: KeyboardEvent) => - ['e', 'E', '+', '-'].includes(evt.key) && evt.preventDefault(); - if (!streams) return null; - - const handleAddStream = (stream: OutputStream) => { - addStream(stream); - }; - - const handleUpdateStream = (key: string, value: string, id: string) => { - const getInt = (val: string) => { - if (Number.isNaN(parseInt(value))) { - return 0; - } - return parseInt(val); - }; - - if (key === 'videoBit') { - const updatedStreams = streams.map((stream) => ({ - ...stream, - videoBit: getInt(value) - })); - updateStreams(updatedStreams); - return; - } - if (key === 'videoKiloBit') { - const updatedStreams = streams.map((stream) => ({ - ...stream, - videoKiloBit: getInt(value) - })); - updateStreams(updatedStreams); - return; - } - if (key === 'videoFormat') { - const updatedStreams = streams.map((stream) => ({ - ...stream, - videoFormat: value - })); - updateStreams(updatedStreams); - return; - } - const streamToUpdate = streams.find((stream) => stream.id === id); - if (streamToUpdate) { - const updatedStream = { - ...streamToUpdate, - [key]: - key === 'port' - ? getInt(value) - : key === 'videoBit' - ? getInt(value) - : key === 'videoKiloBit' - ? getInt(value) - : value - }; - updateStream(updatedStream); - } - }; - - const handleDeleteStream = (id: string, index: number) => { - deleteStream(id, index); - }; - return ( -
    -

    {title}

    -
    - - handleUpdateStream('videoFormat', value, streams[0].id) - } - /> - - handleUpdateStream('videoBit', value, streams[0].id) - } - /> - - - handleUpdateStream('videoKiloBit', value, streams[0].id) - } - /> -
    -
    - {streams.map((stream) => { - return ( - - ); - })} -
    - -
    - ); -} diff --git a/src/components/modal/configureOutputModal/StreamAccordion.tsx b/src/components/modal/configureOutputModal/StreamAccordion.tsx index 1e231d41..502042aa 100644 --- a/src/components/modal/configureOutputModal/StreamAccordion.tsx +++ b/src/components/modal/configureOutputModal/StreamAccordion.tsx @@ -7,8 +7,8 @@ import { OutputStream } from './ConfigureOutputModal'; type StreamAccordionProps = { stream: OutputStream; isOnlyStream: boolean; - update: (key: string, value: string, id: string) => void; - onDelete: (id: string, index: number) => void; + update: (key: string, value: string) => void; + onDelete: () => void; }; export default function StreamAccordion({ @@ -24,7 +24,7 @@ export default function StreamAccordion({ } const handleDelete = () => { - onDelete(stream.id, stream.pipelineIndex); + onDelete(); }; return ( @@ -60,22 +60,22 @@ export default function StreamAccordion({ label={t('preset.mode')} options={[{ label: 'listener' }, { label: 'caller' }]} value={stream.srtMode} - update={(value) => update('srtMode', value, stream.id)} + update={(value) => update('srtMode', value)} /> update('port', value, stream.id)} + update={(value) => update('port', value)} /> update('ip', value, stream.id)} + update={(value) => update('ip', value)} /> update('srtPassphrase', value, stream.id)} + update={(value) => update('srtPassphrase', value)} />
    diff --git a/src/components/startProduction/ConfigureOutputButton.tsx b/src/components/startProduction/ConfigureOutputButton.tsx index b014d471..dfea262c 100644 --- a/src/components/startProduction/ConfigureOutputButton.tsx +++ b/src/components/startProduction/ConfigureOutputButton.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { IconSettings } from '@tabler/icons-react'; import { Preset } from '../../interfaces/preset'; import { useTranslate } from '../../i18n/useTranslate'; @@ -12,41 +12,53 @@ type ConfigureOutputButtonProps = { preset?: Preset; disabled?: boolean; updatePreset: (preset: Preset) => void; - production: Production | undefined; }; export function ConfigureOutputButton({ preset, updatePreset, - disabled, - production + disabled }: ConfigureOutputButtonProps) { const [modalOpen, setModalOpen] = useState(false); + const [isDisabled, setIsDisabled] = useState( + !preset || disabled || false + ); const toggleConfigModal = () => { if (preset) { setModalOpen((state) => !state); } }; + + useEffect(() => { + if (!preset || disabled) { + setIsDisabled(true); + } else { + setIsDisabled(false); + } + }, [preset, disabled]); + const t = useTranslate(); return ( <> - {preset && ( + {preset && !isDisabled && ( )} diff --git a/src/components/startProduction/presetDropdown.tsx b/src/components/startProduction/presetDropdown.tsx index b8191285..f600869f 100644 --- a/src/components/startProduction/presetDropdown.tsx +++ b/src/components/startProduction/presetDropdown.tsx @@ -50,7 +50,7 @@ export const PresetDropdown = ({