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/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/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..c05a41fe 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, @@ -129,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: [ { @@ -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,18 @@ 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) => { + if (source._id !== undefined) { + return source._id.toString(); + } + return ''; + }) ).catch((error) => { if (error === "Can't connect to Database") { throw "Can't connect to Database"; @@ -537,8 +581,8 @@ export async function startProduction( return pipeline.uuid; }) ); - streams = await connectIngestSources( + production.sources, production_settings, sources, usedPorts @@ -611,6 +655,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 +710,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 +781,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/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/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 1b8ce7a8..60596a54 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -1,14 +1,15 @@ 'use client'; + import React, { useEffect, useState, KeyboardEvent, useContext } from 'react'; import { PageProps } from '../../../../.next/types/app/production/[id]/page'; -import { AddSource } from '../../../components/addSource/AddSource'; -import { IconX } from '@tabler/icons-react'; +import { AddInput } from '../../../components/addInput/AddInput'; 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'; @@ -16,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 '../../../contexts/FilterContext'; import { useGetPresets } from '../../../hooks/presets'; import { Preset } from '../../../interfaces/preset'; import SourceCards from '../../../components/sourceCards/SourceCards'; @@ -38,11 +37,14 @@ 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 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(); @@ -52,6 +54,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< @@ -89,13 +94,41 @@ export default function ProductionConfiguration({ params }: PageProps) { const [deleteSourceStatus, setDeleteSourceStatus] = useState(); + // Create source + const [firstEmptySlot] = useGetFirstEmptySlot(); + const [addSource] = useAddSource(); + + // Websocket + const [closeWebsocket] = useWebsocket(); + const { locked } = useContext(GlobalContext); + 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; @@ -222,6 +255,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(), { @@ -371,17 +410,15 @@ 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(() => { + 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); }); @@ -392,28 +429,6 @@ export default function ProductionConfiguration({ params }: PageProps) { return selectedProductionItems?.includes(source._id.toString()); }; - 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 ( @@ -428,11 +443,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) { @@ -449,11 +463,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; @@ -472,12 +487,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; @@ -489,9 +499,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, @@ -517,63 +578,32 @@ 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 ( + 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 (!updatedSetup) return; + if (!inputSlot) return; - setProductionSetup(updatedSetup); - putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { - setRemoveSourceModal(false); - setSelectedSourceRef(undefined); - }); - return; + closeWebsocket(action, inputSlot); } - 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; setProductionSetup(updatedSetup); putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { @@ -594,6 +624,7 @@ export default function ProductionConfiguration({ params }: PageProps) { setSelectedSource(undefined); setDeleteSourceStatus(undefined); }; + return ( <> @@ -686,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) => { @@ -701,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 }, @@ -730,16 +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/image/ImageComponent.tsx b/src/components/image/ImageComponent.tsx index cdb20ee8..df6acf82 100644 --- a/src/components/image/ImageComponent.tsx +++ b/src/components/image/ImageComponent.tsx @@ -10,14 +10,16 @@ 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; + src?: string; alt?: string; + type?: Type; } const ImageComponent: React.FC = (props) => { - const { src, alt = 'Image', children } = props; + const { src, alt = 'Image', children, type } = props; const { imageRefetchIndex } = useContext(GlobalContext); const [imgSrc, setImgSrc] = useState(); const [loaded, setLoaded] = useState(false); @@ -49,45 +51,60 @@ const ImageComponent: React.FC = (props) => { }, []); return ( -
- {((!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%' - }} - /> - - + <> + {(!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} +
)} - {children} -
+ {(type === 'html' || type === 'mediaplayer') && ( + +

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

+
+ )} + ); }; diff --git a/src/components/modal/AddSourceModal.tsx b/src/components/modal/AddSourceModal.tsx index 6687c98b..f7ed7e2d 100644 --- a/src/components/modal/AddSourceModal.tsx +++ b/src/components/modal/AddSourceModal.tsx @@ -29,7 +29,6 @@ export function AddSourceModal({

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

-
{status && }
+ {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 5aa7114f..00000000 --- a/src/components/sourceCard/SourceThumbnail.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import { useState } from 'react'; -import { Source } from '../../interfaces/Source'; -import { IconExclamationCircle } from '@tabler/icons-react'; - -type SourceThumbnailProps = { - source: Source; - src: string; -}; - -export function SourceThumbnail({ source, src }: SourceThumbnailProps) { - const [loaded, setLoaded] = useState(false); - - if (source.status === 'gone') { - return ( -
- -
- ); - } - - return ( - Preview Thumbnail setLoaded(true)} - onError={() => setLoaded(true)} - placeholder="empty" - width={0} - height={0} - sizes="20vh" - style={{ - width: 'auto', - height: '100%' - }} - /> - ); -} diff --git a/src/components/sourceCards/SourceCards.tsx b/src/components/sourceCards/SourceCards.tsx index 20976997..a37b147b 100644 --- a/src/components/sourceCards/SourceCards.tsx +++ b/src/components/sourceCards/SourceCards.tsx @@ -1,12 +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, @@ -18,19 +17,15 @@ export default function SourceCards({ productionSetup: Production; locked: boolean; 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]; @@ -43,59 +38,53 @@ 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 && !locked) { gridItems.push( - setSelectingText(isSelecting) - } + onSelectingText={(isSelecting) => setSelectingText(isSelecting)} /> ); } else { gridItems.push( - setSelectingText(isSelecting) - } + onSelectingText={(isSelecting) => setSelectingText(isSelecting)} /> ); } 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 446c61d4..41ae4f03 100644 --- a/src/components/sourceListItem/SourceListItem.tsx +++ b/src/components/sourceListItem/SourceListItem.tsx @@ -70,7 +70,7 @@ function SourceListItem({ : [] ); } - }, [source.audio_stream.audio_mapping]); + }, [source?.audio_stream.audio_mapping]); return (
  • 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/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..2c1e843d --- /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 || 'ingest_source', + 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/streams.ts b/src/hooks/streams.ts index c684bdde..87aa75cc 100644 --- a/src/hooks/streams.ts +++ b/src/hooks/streams.ts @@ -24,16 +24,16 @@ export function useCreateStream(): CallbackHook< input_slot: number ): Promise> => { setLoading(true); - const stream = { - source: source, - input_slot: input_slot - }; return fetch(`/api/manager/streams/`, { method: 'POST', // TODO: Implement api key headers: [['x-api-key', `Bearer apisecretkey`]], - body: JSON.stringify({ ...stream, production: production }) + body: JSON.stringify({ + source: source, + production: production, + input_slot: input_slot + }) }) .then(async (response) => { if (response.ok) { 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/hooks/useWebsocket.ts b/src/hooks/useWebsocket.ts new file mode 100644 index 00000000..c6333e03 --- /dev/null +++ b/src/hooks/useWebsocket.ts @@ -0,0 +1,20 @@ +import { API_SECRET_KEY } from '../utils/constants'; + +export function useWebsocket() { + const closeWebsocket = async ( + action: 'closeMediaplayer' | 'closeHtml', + inputSlot: number + ) => { + return fetch('/api/manager/websocket', { + method: 'POST', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]], + body: JSON.stringify({ action, inputSlot }) + }).then(async (response) => { + if (response.ok) { + return response.json(); + } + throw await response.text(); + }); + }; + return [closeWebsocket]; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a09a4c3c..dc096051 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 1861e298..9128294d 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).*)/'] };