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..e038dd13 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: [ { 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..d2b4d25a --- /dev/null +++ b/src/app/api/manager/websocket/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; + +const wsUrl = `ws://${process.env.CONTROL_PANEL_WS}`; + +export async function POST(request: Request) { + 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/production/[id]/page.tsx b/src/app/production/[id]/page.tsx index f568e31a..e4eaf844 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -1,10 +1,8 @@ 'use client'; + 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, @@ -19,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'; @@ -46,6 +42,9 @@ 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(); @@ -95,6 +94,13 @@ 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 = @@ -398,65 +404,29 @@ export default function ProductionConfiguration({ params }: PageProps) { ); } - 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 updatedSetup = addSetupItem( - { - _id: source._id.toString(), - type: 'ingest_source', - label: source.ingest_source_name, - // Byt till hook - input_slot: getFirstEmptySlot() - }, - productionSetup - ); - if (!updatedSetup) return; - setProductionSetup(updatedSetup); - putProduction(updatedSetup._id.toString(), updatedSetup).then( - () => { - setAddSourceModal(false); - setSelectedSource(undefined); - } - ); - } - }} - /> - ); - }); - } - 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; - } + 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); + }); } - return firstEmptySlot; + }; + + const isDisabledFunction = (source: SourceWithId): boolean => { + return selectedProductionItems?.includes(source._id.toString()); }; const handleAddSource = async () => { @@ -615,7 +585,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(() => { diff --git a/src/components/image/ImageComponent.tsx b/src/components/image/ImageComponent.tsx index cdb20ee8..c2b724e1 100644 --- a/src/components/image/ImageComponent.tsx +++ b/src/components/image/ImageComponent.tsx @@ -10,14 +10,17 @@ import Image from 'next/image'; import { IconExclamationCircle } from '@tabler/icons-react'; import { Loader } from '../loader/Loader'; import { GlobalContext } from '../../contexts/GlobalContext'; +import { Source, Type } from '../../interfaces/Source'; interface ImageComponentProps extends PropsWithChildren { - src: string; + src?: string; alt?: string; + source?: Source; + 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 +52,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/sourceCard/SourceCard.tsx b/src/components/sourceCard/SourceCard.tsx index 26e7fec9..a2f4b1a4 100644 --- a/src/components/sourceCard/SourceCard.tsx +++ b/src/components/sourceCard/SourceCard.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { ChangeEvent, KeyboardEvent, useState } from 'react'; +import React, { ChangeEvent, KeyboardEvent, useEffect, useState } from 'react'; import { IconTrash } from '@tabler/icons-react'; import { SourceReference, Type } from '../../interfaces/Source'; import { useTranslate } from '../../i18n/useTranslate'; @@ -92,8 +92,10 @@ export default function SourceCard({ disabled={locked} /> - {source && } - {!source && sourceRef && } + {source && !sourceRef && ( + + )} + {!source && sourceRef && } {(source || sourceRef) && (

{ if (source) { onSourceRemoval({ diff --git a/src/components/sourceCards/SourceCards.tsx b/src/components/sourceCards/SourceCards.tsx index c20b8c8a..64a81a1d 100644 --- a/src/components/sourceCards/SourceCards.tsx +++ b/src/components/sourceCards/SourceCards.tsx @@ -54,59 +54,31 @@ export default function SourceCards({ updateProduction={updateProduction} selectingText={selectingText} > - {isSource ? ( - - setSelectingText(isSelecting) - } - type={'ingest_source'} - /> - ) : ( - - setSelectingText(isSelecting) - } - type={source.type} - /> - )} + setSelectingText(isSelecting)} + /> ); } else { - isSource - ? gridItems.push( - - setSelectingText(isSelecting) - } - type={'ingest_source'} - /> - ) - : gridItems.push( - - setSelectingText(isSelecting) - } - type={source.type} - /> - ); + gridItems.push( + setSelectingText(isSelecting)} + /> + ); } return false; } else { diff --git a/src/components/sourceListItem/SourceListItem.tsx b/src/components/sourceListItem/SourceListItem.tsx index be275a67..41ae4f03 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'; diff --git a/src/components/startProduction/StartProductionButton.tsx b/src/components/startProduction/StartProductionButton.tsx index 34f8e2e6..7d60307a 100644 --- a/src/components/startProduction/StartProductionButton.tsx +++ b/src/components/startProduction/StartProductionButton.tsx @@ -48,8 +48,6 @@ 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/pipelines.ts b/src/hooks/pipelines.ts index baa52ffd..f9ef473a 100644 --- a/src/hooks/pipelines.ts +++ b/src/hooks/pipelines.ts @@ -33,7 +33,6 @@ export function usePipeline( setLoading(true); getPipeline(id) .then((pipeline) => { - console.log('pipeline', pipeline); setPipeline(pipeline); }) .catch((error) => { diff --git a/src/hooks/sources/useAddSource.tsx b/src/hooks/sources/useAddSource.tsx index cdd66d61..2c1e843d 100644 --- a/src/hooks/sources/useAddSource.tsx +++ b/src/hooks/sources/useAddSource.tsx @@ -21,7 +21,7 @@ export function useAddSource(): CallbackHook< const updatedSetup = addSetupItem( { _id: input._id ? input._id : undefined, - type: input.type, + type: input.type || 'ingest_source', label: input.label, input_slot: input.input_slot }, 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/useWebsocket.ts b/src/hooks/useWebsocket.ts new file mode 100644 index 00000000..c3c9f2f8 --- /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]; +}