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}
+ />
+
+
+
+ Poster image URL}
+ value={$settings.poster}
+ onChange={handleChange('poster')}
+ />
+
+
+ Upload}
+ acceptTypes={posterImageTypes}
+ onStart={handleUploadStart}
+ onError={handleUploadError(Uploading the poster failed)}
+ onUpload={handlePosterUpload}
+ />
+
+
+
@@ -497,6 +546,11 @@ export default function Edit(props) {
Reset logo
)}
+ {$settings.poster && $tab === 'poster' && (
+
+ )}
}
/>