From 245f69cdcb5db2ffa4f739fd5e0b52810eb8a970 Mon Sep 17 00:00:00 2001 From: Ingo Oppermann Date: Fri, 3 Nov 2023 16:05:56 +0100 Subject: [PATCH] Add option for custom poster image in player --- src/misc/Filesize.js | 28 ++++ src/misc/UploadButton.js | 88 ++++++++++++ src/utils/restreamer.js | 56 +++++++- src/views/Publication/Player.js | 234 ++++++++++++++++++++------------ 4 files changed, 312 insertions(+), 94 deletions(-) create mode 100644 src/misc/Filesize.js create mode 100644 src/misc/UploadButton.js diff --git a/src/misc/Filesize.js b/src/misc/Filesize.js new file mode 100644 index 0000000..eacdd92 --- /dev/null +++ b/src/misc/Filesize.js @@ -0,0 +1,28 @@ +import React from 'react'; + +// Adapted from https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string +export default function Filesize(props) { + let bytes = props.bytes; + const thresh = props.si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = props.si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10 ** props.digits; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + return {bytes.toFixed(props.digits) + ' ' + units[u]}; +} + +Filesize.defaultProps = { + bytes: 0, + si: false, + digits: 1, +}; diff --git a/src/misc/UploadButton.js b/src/misc/UploadButton.js new file mode 100644 index 0000000..360205d --- /dev/null +++ b/src/misc/UploadButton.js @@ -0,0 +1,88 @@ +import React from 'react'; + +import FormInlineButton from './FormInlineButton'; + +export default function UploadButton(props) { + const acceptString = props.acceptTypes.map((t) => t.mimetype).join(','); + + const handleUpload = (event) => { + const handler = (event) => { + const files = event.target.files; + + if (files.length === 0) { + // no files selected + props.onError({ + type: 'nofiles', + }); + return; + } + + const file = files[0]; + + let type = null; + for (let t of props.acceptTypes) { + if (t.mimetype === file.type) { + type = t; + break; + } + } + + if (type === null) { + // not one of the allowed mimetypes + props.onError({ + type: 'mimetype', + actual: file.type, + allowed: acceptString, + }); + return; + } + + if (file.size > type.maxSize) { + // the file is too big + props.onError({ + type: 'size', + actual: file.size, + allowed: type.maxSize, + }); + return; + } + + let reader = new FileReader(); + reader.readAsArrayBuffer(file); + reader.onloadend = async () => { + if (reader.result === null) { + // reading the file failed + props.onError({ + type: 'read', + message: reader.error.message, + }); + return; + } + + props.onUpload(reader.result, type.extension); + }; + }; + + props.onStart(); + + handler(event); + + // reset the value such that the onChange event will be triggered again + // if the same file gets selected again + event.target.value = null; + }; + + return ( + + {props.label} + + + ); +} + +UploadButton.defaultProps = { + label: '', + acceptTypes: [], + onError: function () {}, + onUpload: function (data, extension) {}, +}; diff --git a/src/utils/restreamer.js b/src/utils/restreamer.js index deff8fe..70d3f1b 100644 --- a/src/utils/restreamer.js +++ b/src/utils/restreamer.js @@ -982,6 +982,20 @@ class Restreamer { return address; } + PrefixPublicHTTPAddress(path) { + const address = this.GetPublicHTTPAddress(); + + if (path.match(/^https?:\/\//) !== null) { + return path; + } + + if (path.match(/^\//) === null) { + path = '/' + path; + } + + return address + path; + } + // Get all RTMP/SRT/SNAPSHOT+MEMFS/HLS+MEMFS addresses GetPublicAddress(what, channelid) { const config = this.ConfigActive(); @@ -1826,7 +1840,7 @@ class Restreamer { max_files: parseInt(control.hls.listSize) + 6, max_file_age_seconds: control.hls.cleanup ? parseInt(control.hls.segmentDuration) * (parseInt(control.hls.listSize) + 6) : 0, purge_on_delete: true, - } + }, ); // 4.4 Cleanup hls_master_playlist @@ -2038,6 +2052,7 @@ class Restreamer { color: {}, ga: {}, logo: {}, + poster: '', ...initSettings, }; @@ -2096,6 +2111,11 @@ class Restreamer { airplay: metadata.player.airplay, }; + if (metadata.player.poster.length !== 0) { + templateData.poster = metadata.player.poster.replace(/^\/+/, ''); + templateData.poster_url = this.PrefixPublicHTTPAddress(metadata.player.poster); + } + // upload player.html let player = await this._getLocalAssetAsString(`/_player/${playerType}/player.html`); player = Handlebars.compile(player)(templateData); @@ -2140,6 +2160,14 @@ class Restreamer { }, }; + if (metadata.player.logo.image.length !== 0) { + playerConfig.logo.image = metadata.player.logo.image.replace(/^\/+/, ''); + } + + if (metadata.player.poster.length !== 0) { + playerConfig.poster = metadata.player.poster.replace(/^\/+/, ''); + } + await this._uploadAssetData(`/channels/${channelid}/config.js`, 'var playerConfig = ' + JSON.stringify(playerConfig)); } @@ -2160,6 +2188,23 @@ class Restreamer { return path; } + // Upload a poster image for the selfhosted player + async UploadPoster(channelid, data, extension) { + const channel = this.GetChannel(channelid); + if (channel === null) { + return; + } + + // sanitize extension + extension = extension.replace(/[^0-9a-z]/gi, ''); + + const path = `/channels/${channel.channelid}/poster.${extension}`; + + await this._uploadAssetData(path, data); + + return path; + } + // Playersite // Set defaults for the settings of the playersite @@ -2829,9 +2874,12 @@ class Restreamer { } })(); - this.updates = setTimeout(() => { - this._checkForUpdates(); - }, 1000 * 60 * 60); + this.updates = setTimeout( + () => { + this._checkForUpdates(); + }, + 1000 * 60 * 60, + ); } // Private system related function diff --git a/src/views/Publication/Player.js b/src/views/Publication/Player.js index 2e56595..baf611e 100644 --- a/src/views/Publication/Player.js +++ b/src/views/Publication/Player.js @@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'; import Checkbox from '../../misc/Checkbox'; import Dialog from '../../misc/modals/Dialog'; -import FormInlineButton from '../../misc/FormInlineButton'; +import Filesize from '../../misc/Filesize'; import H from '../../utils/help'; import NotifyContext from '../../contexts/Notify'; import Paper from '../../misc/Paper'; @@ -23,6 +23,7 @@ import PaperHeader from '../../misc/PaperHeader'; import PaperFooter from '../../misc/PaperFooter'; import Player from '../../misc/Player'; import Select from '../../misc/Select'; +import UploadButton from '../../misc/UploadButton'; import TabPanel from '../../misc/TabPanel'; import TabsHorizontal from '../../misc/TabsHorizontal'; import TextFieldCopy from '../../misc/TextFieldCopy'; @@ -56,7 +57,11 @@ const logoImageTypes = [ { mimetype: 'image/svg+xml', extension: 'svg', maxSize: 1 * 1024 * 1024 }, ]; -const logoAcceptString = logoImageTypes.map((t) => t.mimetype).join(','); +const posterImageTypes = [ + { mimetype: 'image/gif', extension: 'gif', maxSize: 1 * 1024 * 1024 }, + { mimetype: 'image/png', extension: 'png', maxSize: 1 * 1024 * 1024 }, + { mimetype: 'image/jpeg', extension: 'jpg', maxSize: 1 * 1024 * 1024 }, +]; export default function Edit(props) { const classes = useStyles(); @@ -151,89 +156,76 @@ export default function Edit(props) { }); }; - const handleLogoUpload = (event) => { - const handler = (event) => { - const files = event.target.files; + const handleLogoUpload = async (data, extension) => { + const path = await props.restreamer.UploadLogo(_channelid, data, extension); - setSaving(true); + handleChange( + 'image', + 'logo', + )({ + target: { + value: path, + }, + }); - if (files.length === 0) { - // no files selected - setSaving(false); - showUploadError(Please select a file to upload.); - return; - } - - const file = files[0]; - - let type = null; - for (let t of logoImageTypes) { - if (t.mimetype === file.type) { - type = t; - break; - } - } - - if (type === null) { - // not one of the allowed mimetypes - setSaving(false); - const types = logoAcceptString; - showUploadError( - - The selected file type ({file.type}) is not allowed. Allowed file types are {types} - - ); - return; - } - - if (file.size > type.maxSize) { - // the file is too big - setSaving(false); - showUploadError( - - The selected file is too big ({file.size} bytes). Only {type.maxSize} bytes are allowed. - - ); - return; - } - - let reader = new FileReader(); - reader.readAsArrayBuffer(file); - reader.onloadend = async () => { - if (reader.result === null) { - // reading the file failed - setSaving(false); - showUploadError(There was an error during upload: {reader.error.message}); - return; - } - - const path = await props.restreamer.UploadLogo(_channelid, reader.result, type.extension); - - handleChange( - 'image', - 'logo' - )({ - target: { - value: address + path, - }, - }); - - setSaving(false); - }; - }; - - handler(event); - - // reset the value such that the onChange event will be triggered again - // if the same file gets selected again - event.target.value = null; + setSaving(false); }; - const showUploadError = (message) => { + const handlePosterUpload = async (data, extension) => { + const path = await props.restreamer.UploadPoster(_channelid, data, extension); + + handleChange('poster')({ + target: { + value: path, + }, + }); + + setSaving(false); + }; + + const handleUploadStart = () => { + setSaving(true); + }; + + const handleUploadError = (title) => (err) => { + let message = null; + + switch (err.type) { + case 'nofiles': + message = Please select a file to upload.; + break; + case 'mimetype': + message = ( + + The selected file type ({err.actual}) is not allowed. Allowed file types are {err.allowed} + + ); + break; + case 'size': + message = ( + + The selected file is too big ( + ). Only are allowed. + + ); + break; + case 'read': + message = There was an error during upload: {err.message}; + break; + default: + message = Unknown upload error; + } + + setSaving(false); + + showUploadError(title, message); + }; + + const showUploadError = (title, message) => { setError({ ...$error, open: true, - title: Uploading the logo failed, + title: title, message: message, }); }; @@ -253,7 +245,7 @@ export default function Edit(props) { handleChange( 'image', - 'logo' + 'logo', )({ target: { value: '', @@ -262,7 +254,7 @@ export default function Edit(props) { handleChange( 'position', - 'logo' + 'logo', )({ target: { value: 'top-left', @@ -271,14 +263,25 @@ export default function Edit(props) { handleChange( 'link', - 'logo' + 'logo', )({ target: { value: '', }, }); + }; - setSaving(false); + const handlePosterReset = (event) => { + // For the cleanup of the core, we need to check the following: + // 1. is the image on the core or external? + // 2. is the image used somewhere else? + // 3. OK via dialog + + handleChange('poster')({ + target: { + value: '', + }, + }); }; const handleDone = async () => { @@ -309,15 +312,34 @@ export default function Edit(props) { H('player-' + $tab); }; + const prepareUrl = (url) => { + if (url.length === 0) { + return ''; + } + + if (url.match(/^https?:\/\//) === null) { + url = address + url; + } + + try { + let u = new URL(url); + u.searchParams.set('_rscache', Math.random()); + return u.href; + } catch (e) { + return url + '?' + Math.random(); + } + }; + if ($ready === false) { return null; } const storage = $metadata.control.hls.storage; const manifest = props.restreamer.GetChannelAddress('hls+' + storage, _channelid); - const poster = props.restreamer.GetChannelAddress('snapshot+' + storage, _channelid); + const poster = $settings.poster ? prepareUrl($settings.poster) : props.restreamer.GetChannelAddress('snapshot+' + storage, _channelid); const playerAddress = props.restreamer.GetPublicAddress('player', _channelid); const iframeCode = props.restreamer.GetPublicIframeCode(_channelid); + const logo = { ...$settings.logo, image: prepareUrl($settings.logo.image) }; return ( @@ -343,7 +365,7 @@ export default function Edit(props) { autoplay={$settings.autoplay} mute={$settings.mute} poster={poster} - logo={$settings.logo} + logo={logo} colors={$settings.color} statistics={$settings.statistics} controls @@ -358,6 +380,7 @@ export default function Edit(props) { Embed} value="embed" /> Logo} value="logo" /> + Poster} value="poster" /> Playback} value="playback" /> @@ -400,17 +423,20 @@ export default function Edit(props) { Image URL} value={$settings.logo.image} onChange={handleChange('image', 'logo')} /> - - Upload - - + Upload} + acceptTypes={logoImageTypes} + onStart={handleUploadStart} + onError={handleUploadError(Uploading the logo failed)} + onUpload={handleLogoUpload} + />