diff --git a/src/misc/Player/videojs.js b/src/misc/Player/videojs.js index 52b3d48..0bbb08d 100644 --- a/src/misc/Player/videojs.js +++ b/src/misc/Player/videojs.js @@ -14,6 +14,15 @@ export default function VideoJS(props) { const playerRef = React.useRef(null); const { options, onReady } = props; + const retryVideo = () => { + const player = playerRef.current; + if (player) { + player.error(null); // Clear the error + player.src(options.sources); // Reload the source + player.play(); // Attempt to play again + } + }; + React.useEffect(() => { // make sure Video.js player is only initialized once if (!playerRef.current) { @@ -32,6 +41,17 @@ export default function VideoJS(props) { } player.addClass('video-js'); player.addClass('vjs-16-9'); + + // retry on MEDIA_ERR_NETWORK = 2 + let retry_count = 0; + player.on('error', () => { + const error = player.error(); + console.log(error); + if (error && (error.code === 2 || error.code === 4) && retry_count < 10) { + retry_count += 1; + setTimeout(retryVideo, 2000); + } + }); } else { // you can update player here [update player through props] // const player = playerRef.current; diff --git a/src/misc/controls/Preview.js b/src/misc/controls/Preview.js new file mode 100644 index 0000000..1078b66 --- /dev/null +++ b/src/misc/controls/Preview.js @@ -0,0 +1,81 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import Grid from '@mui/material/Grid'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; + +import Checkbox from '../Checkbox'; +import Select from '../Select'; + +function init(settings) { + const initSettings = { + enable: true, + video_encoder: 'libx264', + audio_encoder: 'aac', + ...settings, + }; + + return initSettings; +} + +export default function Control(props) { + const settings = init(props.settings); + const encoders = props.encoders; + + // Set the defaults + React.useEffect(() => { + props.onChange(settings, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleChange = (what) => (event) => { + const value = event.target.value; + + if (['enable'].includes(what)) { + settings[what] = !settings[what]; + } else { + settings[what] = value; + } + + props.onChange(settings, false); + }; + + return ( + + + Enable browser-compatible H.264 stream} checked={settings.enable} onChange={handleChange('enable')} /> + + + + + The H.264 encoder used. + + + + ); +} + +Control.defaulProps = { + settings: {}, + onChange: function (settings, automatic) {}, +}; diff --git a/src/utils/metadata.js b/src/utils/metadata.js index fc0eea0..a2b34a3 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -285,6 +285,11 @@ const defaultIngestMetadata = { enable: true, interval: 60, }, + preview: { + enable: false, + video_encoder: 'libx264', + audio_encoder: 'aac', + }, limits: { cpu_usage: 0, memory_mbytes: 0, @@ -472,6 +477,11 @@ const mergeIngestMetadata = (metadata, base) => { ...metadata.control.snapshot, }; + metadata.control.preview = { + ...base.control.preview, + ...metadata.control.preview, + }; + if (!Array.isArray(metadata.sources)) { metadata.sources = []; } else { diff --git a/src/utils/restreamer.js b/src/utils/restreamer.js index 5fcb4ef..c4cbf88 100644 --- a/src/utils/restreamer.js +++ b/src/utils/restreamer.js @@ -1507,15 +1507,21 @@ class Restreamer { } // Get the ingest progress - async GetIngestProgress(channelid) { + async GetIngestProgress(channelid, what) { + // [what] for custom id extentions const channel = this.GetChannel(channelid); if (channel === null) { return this._getProgressFromState(null); } - const state = await this._getProcessState(channel.id); - - return this._getProgressFromState(state); + if (!what) { + const state = await this._getProcessState(`${channel.id}`); + return this._getProgressFromState(state); + } else { + // id=abc, what=_preview = abc_preview + const state = await this._getProcessState(`${channel.id}${what}`); + return this._getProgressFromState(state); + } } // Get the ingest log @@ -1716,71 +1722,49 @@ class Restreamer { // fix Malformed AAC bitstream detected for hls version 7 let hls_aac_adtstoasc = false; - const getHLSParams = (lhls, version) => { - if (lhls) { - // lhls - return [ - ['f', 'dash'], - ['strict', 'experimental'], - ['hls_playlist', '1'], - ['init_seg_name', `init-${channel.channelid}.$ext$`], - ['media_seg_name', `chunk-${channel.channelid}-$Number%05d$.$ext$`], - ['master_m3u8_publish_rate', '1'], - ['adaptation_sets', 'id=0,streams=v id=1,streams=a'], - ['lhls', '1'], - ['streaming', '1'], - ['seg_duration', '' + parseInt(control.hls.segmentDuration)], - ['frag_duration', '0.5'], - ['use_template', '1'], - ['remove_at_exit', '0'], - ['window_size', '' + parseInt(control.hls.listSize)], - ['http_persistent', '0'], - ]; - } else { - // hls - switch (version) { - case 6: - return [ - ['f', 'hls'], - ['start_number', '0'], - ['hls_time', '' + parseInt(control.hls.segmentDuration)], - ['hls_list_size', '' + parseInt(control.hls.listSize)], - ['hls_flags', 'append_list+delete_segments+program_date_time+independent_segments+temp_file'], - ['hls_delete_threshold', '4'], - ['hls_segment_filename', hls_segment_filename], - ]; - case 7: - // fix Malformed AAC bitstream detected for hls version 7 - if (output.options.includes('-codec:a') && output.options.includes('copy')) { - if (!tee_muxer) { - output.options.push('-bsf:a', 'aac_adtstoasc'); - } - hls_aac_adtstoasc = true; + const getHLSParams = (version) => { + switch (version) { + case 6: + return [ + ['f', 'hls'], + ['start_number', '0'], + ['hls_time', '' + parseInt(control.hls.segmentDuration)], + ['hls_list_size', '' + parseInt(control.hls.listSize)], + ['hls_flags', 'append_list+delete_segments+program_date_time+independent_segments+temp_file'], + ['hls_delete_threshold', '4'], + ['hls_segment_filename', hls_segment_filename], + ]; + case 7: + // fix Malformed AAC bitstream detected for hls version 7 + if (output.options.includes('-codec:a') && output.options.includes('copy')) { + if (!tee_muxer) { + output.options.push('-bsf:a', 'aac_adtstoasc'); } - return [ - ['f', 'hls'], - ['start_number', '0'], - ['hls_time', '' + parseInt(control.hls.segmentDuration)], - ['hls_list_size', '' + parseInt(control.hls.listSize)], - ['hls_flags', 'append_list+delete_segments+program_date_time+independent_segments+temp_file'], - ['hls_delete_threshold', '4'], - ['hls_segment_type', 'fmp4'], - ['hls_fmp4_init_filename', hls_fmp4_init_filename], - ['hls_fmp4_init_resend', '1'], - ['hls_segment_filename', hls_segment_filename], - ]; - // case 3 - default: - return [ - ['f', 'hls'], - ['start_number', '0'], - ['hls_time', '' + parseInt(control.hls.segmentDuration)], - ['hls_list_size', '' + parseInt(control.hls.listSize)], - ['hls_flags', 'append_list+delete_segments+program_date_time+temp_file'], - ['hls_delete_threshold', '4'], - ['hls_segment_filename', hls_segment_filename], - ]; - } + hls_aac_adtstoasc = true; + } + return [ + ['f', 'hls'], + ['start_number', '0'], + ['hls_time', '' + parseInt(control.hls.segmentDuration)], + ['hls_list_size', '' + parseInt(control.hls.listSize)], + ['hls_flags', 'append_list+delete_segments+program_date_time+independent_segments+temp_file'], + ['hls_delete_threshold', '4'], + ['hls_segment_type', 'fmp4'], + ['hls_fmp4_init_filename', hls_fmp4_init_filename], + ['hls_fmp4_init_resend', '1'], + ['hls_segment_filename', hls_segment_filename], + ]; + // case 3 + default: + return [ + ['f', 'hls'], + ['start_number', '0'], + ['hls_time', '' + parseInt(control.hls.segmentDuration)], + ['hls_list_size', '' + parseInt(control.hls.listSize)], + ['hls_flags', 'append_list+delete_segments+program_date_time+temp_file'], + ['hls_delete_threshold', '4'], + ['hls_segment_filename', hls_segment_filename], + ]; } }; const hls_params_raw = getHLSParams(control.hls.lhls, control.hls.version); @@ -1945,8 +1929,8 @@ class Restreamer { }, ], options: ['-err_detect', 'ignore_err'], - autostart: control.process.autostart, - reconnect: true, + autostart: control.snapshot.enable ? control.process.autostart : false, + reconnect: control.snapshot.enable ? true : false, reconnect_delay_seconds: parseInt(control.snapshot.interval), stale_timeout_seconds: 30, }; @@ -1959,6 +1943,99 @@ class Restreamer { return [val, null]; } + // Upsert the ingest browser playback process (preview) + async UpsertIngestPreview(channelid, control) { + const channel = this.GetChannel(channelid); + if (channel === null) { + return [null, { message: 'Unknown channel ID' }]; + } + + // Set hls storage endpoint + const hlsStorage = control.hls.storage; + + // Set encoder settings + const video_encoder = control.preview.video_encoder; + const audio_encoder = control.preview.audio_encoder; + + const preview = { + type: 'ffmpeg', + id: channel.id + '_h264', + reference: channel.channelid, + input: [ + { + id: 'input_0', + address: `{${hlsStorage}}/${channel.channelid}.m3u8`, + options: [], + }, + ], + output: [ + { + id: 'output_0', + address: `{memfs}/${channel.channelid}_output_0_h264.m3u8`, + options: [ + '-c:v', + `${video_encoder}`, + '-r', + '25', + '-g', + '50', + '-sc_threshold', + '0', + '-pix_fmt', + 'yuv420p', + '-c:a', + `${audio_encoder}`, + '-f', + 'hls', + '-start_number', + '0', + '-hls_time', + '2', + '-hls_list_size', + '6', + '-hls_flags', + 'append_list+delete_segments+program_date_time+temp_file', + '-hls_delete_threshold', + '4', + '-hls_segment_filename', + `{memfs}/${channel.channelid}_output_0_h264_%04d.ts`, + '-master_pl_name', + `${channel.channelid}_h264.m3u8`, + '-master_pl_publish_rate', + '2', + ], + cleanup: [ + { + pattern: `memfs:/${channel.channelid}_h264.m3u8`, + purge_on_delete: true, + }, + { + pattern: `memfs:/${channel.channelid}_output_0_h264.m3u8`, + purge_on_delete: true, + }, + { + pattern: `memfs:/${channel.channelid}_output_0_h264_*.ts`, + max_files: 12, + purge_on_delete: true, + }, + ], + }, + ], + options: ['-err_detect', 'ignore_err'], + autostart: control.preview.enable ? control.process.autostart : false, + reconnect: control.preview.enable ? control.process.reconnect : false, + reconnect_delay_seconds: 2, + stale_timeout_seconds: 5, + }; + + const [val, err] = await this._upsertProcess(channel.id + '_h264', preview); + if (err !== null) { + return [val, err]; + } + + return [val, null]; + } + // Check whether the manifest of the ingest process is available async HasIngestFiles(channelid) { const channel = this.GetChannel(channelid); @@ -3410,6 +3487,8 @@ class Restreamer { command: [], cpu: 0, memory: 0, + video_codec: '', + audio_codec: '', }; if (state === null) { @@ -3452,8 +3531,16 @@ class Restreamer { progress.dup = state.progress.dup || 0; progress.cpu = state.cpu_usage || 0; progress.memory = state.memory_bytes || 0; - } + // check av codec @ preview + for (const o in state.progress.outputs) { + if (state.progress.outputs[o].type === 'video') { + progress.video_codec = state.progress.outputs[o].codec; + } else if (state.progress.outputs[o].type === 'audio') { + progress.audio_codec = state.progress.outputs[o].codec; + } + } + } return progress; } diff --git a/src/views/Edit/index.js b/src/views/Edit/index.js index a5fa17c..cbea87c 100644 --- a/src/views/Edit/index.js +++ b/src/views/Edit/index.js @@ -29,6 +29,7 @@ import Paper from '../../misc/Paper'; import PaperHeader from '../../misc/PaperHeader'; import PaperFooter from '../../misc/PaperFooter'; import PaperThumb from '../../misc/PaperThumb'; +import PreviewControl from '../../misc/controls/Preview'; import ProcessControl from '../../misc/controls/Process'; import Profile from './Profile'; import ProfileSummary from './ProfileSummary'; @@ -322,6 +323,12 @@ export default function Edit(props) { notify.Dispatch('error', 'save:ingest', i18n._(t`Failed to update ingest snapshot process (${err.message})`)); } + // Create/update the ingest preview process + [, err] = await props.restreamer.UpsertIngestPreview(_channelid, control); + if (err !== null) { + notify.Dispatch('error', 'save:ingest', i18n._(t`Failed to update ingest preview process (${err.message})`)); + } + // Create/update the player res = await props.restreamer.UpdatePlayer(_channelid); if (res === false) { @@ -549,6 +556,21 @@ export default function Edit(props) { + + + Player Playback + + + + + + + + Process diff --git a/src/views/Main/index.js b/src/views/Main/index.js index d242617..35a3956 100644 --- a/src/views/Main/index.js +++ b/src/views/Main/index.js @@ -38,7 +38,7 @@ const useStyles = makeStyles((theme) => ({ playerL1: { //padding: '4px 1px 4px 8px', paddingTop: 10, - paddingLeft: 18 + paddingLeft: 18, }, playerL2: { position: 'relative', @@ -69,6 +69,7 @@ export default function Main(props) { progress: {}, state: 'disconnected', onConnect: null, + preview: null, }); const [$metadata, setMetadata] = React.useState(M.getDefaultIngestMetadata()); const [$processDetails, setProcessDetails] = React.useState({ @@ -149,6 +150,15 @@ export default function Main(props) { }, 100); state.onConnect = null; } + // check av codec @ preview + if (state.progress.video_codec !== 'h264' && $state.preview === null) { + const preview_progress = await props.restreamer.GetIngestProgress(`${_channelid}`, '_preview'); + if (preview_progress) { + state.preview = false; + } else { + state.preview = true; + } + } } if ($metadata.control.rtmp.enable) { @@ -287,11 +297,16 @@ export default function Main(props) { const storage = $metadata.control.hls.storage; const channel = props.restreamer.GetChannel(_channelid); const manifest = props.restreamer.GetChannelAddress('hls+' + storage, _channelid); + const manifest_preview = props.restreamer.GetChannelAddress('hls+' + storage, `${_channelid}_h264`); const poster = props.restreamer.GetChannelAddress('snapshot+' + storage, _channelid); - let title = Main channel; + let title = {$state.progress.video_codec} - Main channel; if (channel && channel.name && channel.name.length !== 0) { - title = channel.name; + if ($state.progress.video_codec) { + title = `${$state.progress.video_codec} - ${channel.name}`; + } else { + title = `${channel.name}`; + } } return ( @@ -382,9 +397,42 @@ export default function Main(props) { )} )} - {$state.state === 'connected' && ( + {$state.state === 'connected' && $state.progress.video_codec === 'h264' && ( )} + {$state.state === 'connected' && $state.progress.video_codec !== 'h264' && $metadata.control.preview.enable && ( + + )} + {$state.state === 'connected' && $state.progress.video_codec !== 'h264' && !$metadata.control.preview.enable && ( + + + + + + + No H.264 Stream availabe. + + + + + + Please{' '} + navigate(`/${_channelid}/edit`)}> + edit + {' '} + this channel and enable the browser-compatible H.264 stream in the "Processing & Control" area: + + + + + )} @@ -433,6 +481,16 @@ export default function Main(props) { > Snapshot + {$metadata.control.preview.enable && ( + + HLS @ H.264 + + )}