Add option for custom poster image in player
This commit is contained in:
parent
a68a39b9f9
commit
245f69cdcb
28
src/misc/Filesize.js
Normal file
28
src/misc/Filesize.js
Normal 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
88
src/misc/UploadButton.js
Normal 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) {},
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user