diff --git a/package.json b/package.json index a6cfd4f56..8484a627a 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@reduxjs/toolkit": "^1.8.1", "@sentry/browser": "^7.74.1", "@sentry/react": "^7.74.1", + "@soapbox.pub/wasmboy": "^0.8.0", "@tabler/icons": "^2.0.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.3", diff --git a/src/components/gameboy.tsx b/src/components/gameboy.tsx new file mode 100644 index 000000000..bc8197fd1 --- /dev/null +++ b/src/components/gameboy.tsx @@ -0,0 +1,173 @@ +// @ts-ignore No types available +import { WasmBoy } from '@soapbox.pub/wasmboy'; +import clsx from 'clsx'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { exitFullscreen, isFullscreen, requestFullscreen } from 'soapbox/features/ui/util/fullscreen'; + +import { IconButton } from './ui'; + +let gainNode: GainNode | undefined; + +interface IGameboy extends Pick, 'onFocus' | 'onBlur'> { + /** Classname of the outer `
`. */ + className?: string; + /** URL to the ROM. */ + src: string; + /** Aspect ratio of the canvas. */ + aspect?: 'normal' | 'stretched'; +} + +/** Component to display a playable Gameboy emulator. */ +const Gameboy: React.FC = ({ className, src, aspect = 'normal', onFocus, onBlur, ...rest }) => { + const node = useRef(null); + const canvas = useRef(null); + + const [paused, setPaused] = useState(false); + const [muted, setMuted] = useState(true); + const [fullscreen, setFullscreen] = useState(false); + const [showControls, setShowControls] = useState(true); + + async function init() { + await WasmBoy.config(WasmBoyOptions, canvas.current!); + await WasmBoy.loadROM(src); + await play(); + + if (document.activeElement === canvas.current) { + await WasmBoy.enableDefaultJoypad(); + } else { + await WasmBoy.disableDefaultJoypad(); + } + } + + const handleFocus: React.FocusEventHandler = useCallback(() => { + WasmBoy.enableDefaultJoypad(); + }, []); + + const handleBlur: React.FocusEventHandler = useCallback(() => { + WasmBoy.disableDefaultJoypad(); + }, []); + + const handleFullscreenChange = useCallback(() => { + setFullscreen(isFullscreen()); + }, []); + + const handleCanvasClick = useCallback(() => { + setShowControls(!showControls); + }, [showControls]); + + const pause = async () => { + await WasmBoy.pause(); + setPaused(true); + }; + + const play = async () => { + await WasmBoy.play(); + setPaused(false); + }; + + const togglePaused = () => paused ? play() : pause(); + const toggleMuted = () => setMuted(!muted); + + const toggleFullscreen = () => { + if (isFullscreen()) { + exitFullscreen(); + } else if (node.current) { + requestFullscreen(node.current); + } + }; + + useEffect(() => { + init(); + + return () => { + WasmBoy.pause(); + WasmBoy.disableDefaultJoypad(); + }; + }, []); + + useEffect(() => { + document.addEventListener('fullscreenchange', handleFullscreenChange, true); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange, true); + }; + }, []); + + useEffect(() => { + if (fullscreen) { + node.current?.focus(); + } + }, [fullscreen]); + + useEffect(() => { + if (gainNode) { + gainNode.gain.value = muted ? 0 : 1; + } + }, [gainNode, muted]); + + return ( +
+ + +
+ + + +
+
+ ); +}; + +const WasmBoyOptions = { + headless: false, + useGbcWhenOptional: true, + isAudioEnabled: true, + frameSkip: 1, + audioBatchProcessing: true, + timersBatchProcessing: false, + audioAccumulateSamples: true, + graphicsBatchProcessing: false, + graphicsDisableScanlineRendering: false, + tileRendering: true, + tileCaching: true, + gameboyFPSCap: 60, + updateGraphicsCallback: false, + updateAudioCallback: (audioContext: AudioContext, audioBufferSourceNode: AudioBufferSourceNode) => { + gainNode = gainNode ?? audioContext.createGain(); + audioBufferSourceNode.connect(gainNode); + return gainNode; + }, + saveStateCallback: false, +}; + +export default Gameboy; \ No newline at end of file diff --git a/src/components/media-gallery.tsx b/src/components/media-gallery.tsx index 0d8e48683..db7bfd1d6 100644 --- a/src/components/media-gallery.tsx +++ b/src/components/media-gallery.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import React, { useState, useRef, useLayoutEffect } from 'react'; +import React, { useState, useRef, useLayoutEffect, Suspense } from 'react'; import Blurhash from 'soapbox/components/blurhash'; import Icon from 'soapbox/components/icon'; @@ -15,6 +15,8 @@ import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maxi import type { Property } from 'csstype'; import type { List as ImmutableList } from 'immutable'; +const Gameboy = React.lazy(() => import('./gameboy')); + const ATTACHMENT_LIMIT = 4; const MAX_FILENAME_LENGTH = 45; @@ -141,8 +143,24 @@ const Item: React.FC = ({ } let thumbnail: React.ReactNode = ''; + const ext = attachment.url.split('.').pop()?.toLowerCase(); - if (attachment.type === 'unknown') { + if (attachment.type === 'unknown' && ['gb', 'gbc'].includes(ext!)) { + return ( +
1, + })} + key={attachment.id} + style={{ position, float, left, top, right, bottom, height, width: `${width}%` }} + > + }> + + +
+ ); + } else if (attachment.type === 'unknown') { const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH); const attachmentIcon = ( = ({
); } else if (attachment.type === 'audio') { - const ext = attachment.url.split('.').pop()?.toUpperCase(); thumbnail = ( = ({ title={attachment.description} > - {ext} + {ext} ); } else if (attachment.type === 'video') { - const ext = attachment.url.split('.').pop()?.toUpperCase(); thumbnail = ( = ({ > - {ext} + {ext} ); } diff --git a/vite.config.ts b/vite.config.ts index 4f93c42d3..273ebb87b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ import { VitePWA } from 'vite-plugin-pwa'; import vitePluginRequire from 'vite-plugin-require'; import { viteStaticCopy } from 'vite-plugin-static-copy'; -export default defineConfig({ +export default defineConfig(({ command }) => ({ build: { assetsDir: 'packs', assetsInlineLimit: 0, @@ -29,6 +29,9 @@ export default defineConfig({ server: { port: 3036, }, + optimizeDeps: { + exclude: command === 'serve' ? ['@soapbox.pub/wasmboy'] : [], + }, plugins: [ checker({ typescript: true }), // @ts-ignore @@ -100,7 +103,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: 'src/jest/test-setup.ts', }, -}); +})); /** Return file as string, or return empty string if the file isn't found. */ function readFileContents(path: string) { diff --git a/yarn.lock b/yarn.lock index a2d4c7e5d..08b18ba00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2100,6 +2100,16 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== +"@soapbox.pub/wasmboy@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@soapbox.pub/wasmboy/-/wasmboy-0.8.0.tgz#ca7e86b9c144af44530223536fdbcd29930953b1" + integrity sha512-feecE/YHmE7RdKe5IWzTCeLSaO3gF2XtHqAZWZ31YGzm0Fc8vAxuZC2Tyt0lrKRsS7rEs2L1SxT9oYAiEJcOZA== + dependencies: + audiobuffer-to-wav "git+https://github.com/torch2424/audiobuffer-to-wav.git#es-module-rollup" + idb "^2.1.3" + raf "^3.4.0" + responsive-gamepad "1.1.0" + "@surma/rollup-plugin-off-main-thread@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" @@ -3108,6 +3118,10 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +"audiobuffer-to-wav@git+https://github.com/torch2424/audiobuffer-to-wav.git#es-module-rollup": + version "1.0.0" + resolved "git+https://github.com/torch2424/audiobuffer-to-wav.git#8878a20c5cc7e457b113dabfb1781ad4178f9c62" + autoprefixer@^10.4.15: version "10.4.15" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.15.tgz#a1230f4aeb3636b89120b34a1f513e2f6834d530" @@ -5197,6 +5211,11 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +idb@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/idb/-/idb-2.1.3.tgz#7b295fa1a46ab7851e42dd85543a271435f87fee" + integrity sha512-1He6QAuavrD38HCiJasi4lEEK87Y22ldFuM+ZHkp433n4Fd5jXjWKutClYFp8w4mgx3zgrjnWxL8dpjMzcQ+WQ== + idb@^7.0.1: version "7.1.1" resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" @@ -7138,7 +7157,7 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== -raf@^3.1.0: +raf@^3.1.0, raf@^3.4.0: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== @@ -7709,6 +7728,11 @@ responselike@^2.0.0: dependencies: lowercase-keys "^2.0.0" +responsive-gamepad@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/responsive-gamepad/-/responsive-gamepad-1.1.0.tgz#0173d05199e3c41c09f3b1b9fa8eb811dad8e585" + integrity sha512-njsJuKvany9eYjywXm8iorTeXeAAPqwMNaRWOo8jlh0iQboXgGPf6Z6bLGntELrfU+vR94jTPJYRW0Zzb2gaRA== + restore-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"