From c01ebf99d629029288a2ea7cf6874ca076d87f70 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Sun, 30 Jul 2023 16:35:01 +0200 Subject: [PATCH] add APNG player --- package-lock.json | 11 ++ package.json | 1 + resources/js/components/common/Icon.js | 5 + resources/js/components/common/PngPlayer.js | 122 ++++++++++++++++++++ resources/js/i18n/de.js | 4 + resources/js/i18n/en.js | 4 + resources/sass/common.scss | 25 ++++ 7 files changed, 172 insertions(+) create mode 100644 resources/js/components/common/PngPlayer.js diff --git a/package-lock.json b/package-lock.json index 99cc398..7bb082e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@fortawesome/free-brands-svg-icons": "^6.0.0", "@fortawesome/free-solid-svg-icons": "^6.0.0", "@fortawesome/react-fontawesome": "^0.1.17", + "apng-js": "^1.1.1", "crc-32": "^1.2.2", "file-saver": "^2.0.5", "formik": "^2.2.9", @@ -2852,6 +2853,11 @@ "node": ">= 8" } }, + "node_modules/apng-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/apng-js/-/apng-js-1.1.1.tgz", + "integrity": "sha512-UWaloDssWCE8Bj0wipyNxEXPnMadYS0VAjghCLas5nKGqfiBMNdQJhg8Fawq2+jZ50IOM1feKwjiqPAC/bvKgg==" + }, "node_modules/arg": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", @@ -14259,6 +14265,11 @@ "picomatch": "^2.0.4" } }, + "apng-js": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/apng-js/-/apng-js-1.1.1.tgz", + "integrity": "sha512-UWaloDssWCE8Bj0wipyNxEXPnMadYS0VAjghCLas5nKGqfiBMNdQJhg8Fawq2+jZ50IOM1feKwjiqPAC/bvKgg==" + }, "arg": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", diff --git a/package.json b/package.json index 2429eb1..ed394ec 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@fortawesome/free-brands-svg-icons": "^6.0.0", "@fortawesome/free-solid-svg-icons": "^6.0.0", "@fortawesome/react-fontawesome": "^0.1.17", + "apng-js": "^1.1.1", "crc-32": "^1.2.2", "file-saver": "^2.0.5", "formik": "^2.2.9", diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index ddd35fe..022de69 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -79,8 +79,10 @@ Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt'); Icon.MICROPHONE = makePreset('MicrophoneIcon', 'microphone'); Icon.MONITOR = makePreset('MonitorIcon', 'tv'); Icon.MOUSE = makePreset('MouseIcon', 'arrow-pointer'); +Icon.PAUSE = makePreset('PauseIcon', 'pause'); Icon.PENDING = makePreset('PendingIcon', 'clock'); Icon.PIN = makePreset('PinIcon', 'location-pin'); +Icon.PLAY = makePreset('PlayIcon', 'play'); Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt'); Icon.REJECT = makePreset('RejectIcon', 'square-xmark'); Icon.REMOVE = makePreset('RemoveIcon', 'square-xmark'); @@ -88,6 +90,9 @@ Icon.RESULT = makePreset('ResultIcon', 'clock'); Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal'); Icon.SETTINGS = makePreset('SettingsIcon', 'cog'); Icon.SLASH = makePreset('SlashIcon', 'slash'); +Icon.STEP_BACKWARD = makePreset('StepBackwardIcon', 'backward-step'); +Icon.STEP_FORWARD = makePreset('StepForwardIcon', 'forward-step'); +Icon.STOP = makePreset('StopIcon', 'stop'); Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']); Icon.THIRD_PLACE = makePreset('ThirdPlaceIcon', 'award'); Icon.TWITCH = makePreset('TwitchIcon', ['fab', 'twitch']); diff --git a/resources/js/components/common/PngPlayer.js b/resources/js/components/common/PngPlayer.js new file mode 100644 index 0000000..4bdb41a --- /dev/null +++ b/resources/js/components/common/PngPlayer.js @@ -0,0 +1,122 @@ +import parseApng from 'apng-js'; +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import Icon from './Icon'; + +const createPlayer = async (apng, canvas) => { + const context = canvas.getContext('2d', { willReadFrequently: true }); + const player = await apng.getPlayer(context); + player.stop(); + return player; +}; + +const PngPlayer = ({ src }) => { + const canvas = React.useRef(); + const { t } = useTranslation(); + + const [apng, setApng] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [player, setPlayer] = React.useState(null); + + React.useEffect(() => { + setError(null); + setLoading(true); + const ctrl = new AbortController(); + const fetchPng = async () => { + try { + const response = await axios.get(src, { + responseType: 'arraybuffer', + signal: ctrl.signal, + }); + const png = parseApng(response.data); + await png.createImages(); + setApng(png); + setLoading(false); + } catch (e) { + if (!axios.isCancel(e)) { + setError(e); + console.log(e); + } + } + }; + fetchPng(); + return () => { + ctrl.abort(); + }; + }, [src]); + + React.useEffect(async () => { + if (loading || !canvas.current) return; + setPlayer(await createPlayer(apng, canvas.current)); + }, [apng, canvas.current, loading]); + + const play = React.useCallback(() => { + if (player) player.play(); + }, [player]); + + const pause = React.useCallback(() => { + if (player) player.pause(); + }, [player]); + + const stop = React.useCallback(() => { + if (player) player.stop(); + }, [player]); + + const nextFrame = React.useCallback(() => { + if (player) player.renderNextFrame(); + }, [player]); + + if (error) { + return
Error
; + } + if (loading) { + return
Loading
; + } + + return
+
+ +
+
+ + + + +
+
; +}; + +PngPlayer.propTypes = { + src: PropTypes.string, +}; + +export default PngPlayer; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index ae88eaa..8f7cba8 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -259,6 +259,9 @@ export default { login: 'Login', logout: 'Logout', new: 'Neu', + nextFrame: 'Nächster Frame', + pause: 'Pause', + play: 'Play', protocol: 'Protokoll', remove: 'Entfernen', retry: 'Neu versuchen', @@ -266,6 +269,7 @@ export default { search: 'Suche', settings: 'Einstellungen', signUp: 'Anmelden', + stop: 'Stop', unconfirm: 'Zurückziehen', }, crew: { diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index dc84f67..273682f 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -259,6 +259,9 @@ export default { login: 'Login', logout: 'Logout', new: 'New', + nextFrame: 'Next frame', + pause: 'Pause', + play: 'Play', protocol: 'Protocol', remove: 'Remove', retry: 'Retry', @@ -266,6 +269,7 @@ export default { search: 'Search', settings: 'Settings', signUp: 'Sign up', + stop: 'Stop', unconfirm: 'Retract', }, crew: { diff --git a/resources/sass/common.scss b/resources/sass/common.scss index 14cbd07..3e88649 100644 --- a/resources/sass/common.scss +++ b/resources/sass/common.scss @@ -23,6 +23,31 @@ h1 { max-width: none !important; } +.png-player { + display: flex; + flex-direction: column; + align-items: center; + + .screen { + background: black; + display: flex; + width: 100%; + height: auto; + + canvas { + flex-grow: 1; + -ms-interpolation-mode: nearest-neighbor; + image-rendering: crisp-edges; + image-rendering: pixelated; + } + } + + .controls { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } +} + .quote-alert { position: relative; &::after { -- 2.39.2