Add option for custom poster image in player

This commit is contained in:
Ingo Oppermann 2023-11-03 16:05:56 +01:00
parent a68a39b9f9
commit 245f69cdcb
No known key found for this signature in database
GPG Key ID: 2AB32426E9DD229E
4 changed files with 312 additions and 94 deletions

28
src/misc/Filesize.js Normal file
View File

@ -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 <React.Fragment>{bytes.toFixed(props.digits) + ' ' + units[u]}</React.Fragment>;
}
Filesize.defaultProps = {
bytes: 0,
si: false,
digits: 1,
};

88
src/misc/UploadButton.js Normal file
View File

@ -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 (
<FormInlineButton component="label">
{props.label}
<input accept={acceptString} type="file" hidden onChange={handleUpload} />
</FormInlineButton>
);
}
UploadButton.defaultProps = {
label: '',
acceptTypes: [],
onError: function () {},
onUpload: function (data, extension) {},
};

View File

@ -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

View File

@ -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(<Trans>Please select a file to upload.</Trans>);
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(
<Trans>
The selected file type ({file.type}) is not allowed. Allowed file types are {types}
</Trans>
);
return;
}
if (file.size > type.maxSize) {
// the file is too big
setSaving(false);
showUploadError(
<Trans>
The selected file is too big ({file.size} bytes). Only {type.maxSize} bytes are allowed.
</Trans>
);
return;
}
let reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onloadend = async () => {
if (reader.result === null) {
// reading the file failed
setSaving(false);
showUploadError(<Trans>There was an error during upload: {reader.error.message}</Trans>);
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 = <Trans>Please select a file to upload.</Trans>;
break;
case 'mimetype':
message = (
<Trans>
The selected file type ({err.actual}) is not allowed. Allowed file types are {err.allowed}
</Trans>
);
break;
case 'size':
message = (
<Trans>
The selected file is too big (<Filesize bytes={err.actual} />
). Only <Filesize bytes={err.allowed} /> are allowed.
</Trans>
);
break;
case 'read':
message = <Trans>There was an error during upload: {err.message}</Trans>;
break;
default:
message = <Trans>Unknown upload error</Trans>;
}
setSaving(false);
showUploadError(title, message);
};
const showUploadError = (title, message) => {
setError({
...$error,
open: true,
title: <Trans>Uploading the logo failed</Trans>,
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 (
<React.Fragment>
@ -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) {
<TabsHorizontal value={$tab} onChange={handleChangeTab}>
<Tab className="tab" label={<Trans>Embed</Trans>} value="embed" />
<Tab className="tab" label={<Trans>Logo</Trans>} value="logo" />
<Tab className="tab" label={<Trans>Poster</Trans>} value="poster" />
<Tab className="tab" label={<Trans>Playback</Trans>} value="playback" />
</TabsHorizontal>
<TabPanel value={$tab} index="embed">
@ -400,17 +423,20 @@ export default function Edit(props) {
<TextField
variant="outlined"
fullWidth
id="image-url"
id="logo-url"
label={<Trans>Image URL</Trans>}
value={$settings.logo.image}
onChange={handleChange('image', 'logo')}
/>
</Grid>
<Grid item xs={12} md={3}>
<FormInlineButton component="label">
<Trans>Upload</Trans>
<input accept={logoAcceptString} type="file" hidden onChange={handleLogoUpload} />
</FormInlineButton>
<UploadButton
label={<Trans>Upload</Trans>}
acceptTypes={logoImageTypes}
onStart={handleUploadStart}
onError={handleUploadError(<Trans>Uploading the logo failed</Trans>)}
onUpload={handleLogoUpload}
/>
</Grid>
<Grid item xs={12} md={4}>
<Select
@ -430,7 +456,7 @@ export default function Edit(props) {
<TextField
variant="outlined"
fullWidth
id="image-link"
id="logo-link"
label={<Trans>Link</Trans>}
value={$settings.logo.link}
onChange={handleChange('link', 'logo')}
@ -438,6 +464,29 @@ export default function Edit(props) {
</Grid>
</Grid>
</TabPanel>
<TabPanel value={$tab} index="poster">
<Grid container spacing={2}>
<Grid item xs={12} md={9}>
<TextField
variant="outlined"
fullWidth
id="poster-url"
label={<Trans>Poster image URL</Trans>}
value={$settings.poster}
onChange={handleChange('poster')}
/>
</Grid>
<Grid item xs={12} md={3}>
<UploadButton
label={<Trans>Upload</Trans>}
acceptTypes={posterImageTypes}
onStart={handleUploadStart}
onError={handleUploadError(<Trans>Uploading the poster failed</Trans>)}
onUpload={handlePosterUpload}
/>
</Grid>
</Grid>
</TabPanel>
<TabPanel value={$tab} index="statistic">
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
@ -497,6 +546,11 @@ export default function Edit(props) {
<Trans>Reset logo</Trans>
</Button>
)}
{$settings.poster && $tab === 'poster' && (
<Button variant="outlined" color="secondary" onClick={handlePosterReset}>
<Trans>Reset poster</Trans>
</Button>
)}
</React.Fragment>
}
/>