diff --git a/package.json b/package.json index 309ca50a7..375372f49 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,9 @@ "esbuild": "^0.19.3", "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.12", + "flying-squid": "npm:@zardoy/flying-squid@^0.0.13", "fs-extra": "^11.1.1", + "google-drive-browserfs": "github:zardoy/browserfs#google-drive", "iconify-icon": "^1.0.8", "jszip": "^3.10.1", "lit": "^2.8.0", @@ -61,6 +62,7 @@ "node-gzip": "^1.1.2", "peerjs": "^1.5.0", "pretty-bytes": "^6.1.1", + "prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything", "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -74,8 +76,7 @@ "title-case": "3.x", "ua-parser-js": "^1.0.37", "valtio": "^1.11.1", - "workbox-build": "^7.0.0", - "google-drive-browserfs": "github:zardoy/browserfs#google-drive" + "workbox-build": "^7.0.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 174d0b0c9..d3ed94c4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,8 +85,8 @@ importers: specifier: ^4.18.2 version: 4.18.2 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.12 - version: /@zardoy/flying-squid@0.0.12 + specifier: npm:@zardoy/flying-squid@^0.0.13 + version: /@zardoy/flying-squid@0.0.13 fs-extra: specifier: ^11.1.1 version: 11.1.1 @@ -123,6 +123,9 @@ importers: pretty-bytes: specifier: ^6.1.1 version: 6.1.1 + prismarine-provider-anvil: + specifier: github:zardoy/prismarine-provider-anvil#everything + version: github.com/zardoy/prismarine-provider-anvil/0ddcd9d48574113308e1fbebef60816aced0846f(minecraft-data@3.62.0) qrcode.react: specifier: ^3.1.0 version: 3.1.0(react@18.2.0) @@ -5655,8 +5658,8 @@ packages: tslib: 1.14.1 dev: true - /@zardoy/flying-squid@0.0.12: - resolution: {integrity: sha512-wFvdROB9iEucdYamBLXhKKGiUdprjxJsSo0Mk4UKMUoau9G3oly1tVfkuVZc9mQm0NSLOx8oSPA3uCNUT9lAgw==} + /@zardoy/flying-squid@0.0.13: + resolution: {integrity: sha512-K4vjMx+pi+Xbmm/m6xb17hml8w+0Bk89SiqGuDiB5zaRcTup7K5iqmZ2STQRrTZkjxSNh+eX26R67x2l0XHBIg==} engines: {node: '>=8'} hasBin: true dependencies: @@ -5671,7 +5674,7 @@ packages: minecraft-data: 3.62.0 minecraft-protocol: github.com/zardoy/minecraft-protocol/2c14a686bfe7cbd9a5c87b629b402295ee86219f mkdirp: 2.1.6 - moment: 2.29.4 + moment: 2.30.1 needle: 2.9.1 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -11149,8 +11152,8 @@ packages: dependencies: nearley: 2.20.1 - /moment@2.29.4: - resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} + /moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} dev: false /moo@0.5.2: diff --git a/src/dragndrop.ts b/src/dragndrop.ts index 83aeb99fb..034b7b9b0 100644 --- a/src/dragndrop.ts +++ b/src/dragndrop.ts @@ -4,7 +4,8 @@ import * as nbt from 'prismarine-nbt' import RegionFile from 'prismarine-provider-anvil/src/region' import { versions } from 'minecraft-data' import { openWorldDirectory, openWorldZip } from './browserfs' -import { isGameActive, showNotification } from './globalState' +import { isGameActive } from './globalState' +import { showNotification } from './react/NotificationProvider' const parseNbt = promisify(nbt.parse) const simplifyNbt = nbt.simplify @@ -100,9 +101,7 @@ async function handleDroppedFile (file: File) { alert('Couldn\'t parse nbt, ensure you are opening .dat or file (or .zip/folder with a world)') throw err }) - showNotification({ - message: `${file.name} data available in browser console`, - }) + showNotification(`${file.name} data available in browser console`) console.log('raw', parsed) console.log('simplified', nbt.simplify(parsed)) } diff --git a/src/globalState.ts b/src/globalState.ts index adaf43612..749b3a8fc 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -158,17 +158,4 @@ export const gameAdditionalState = proxy({ window.gameAdditionalState = gameAdditionalState -// rename current (non-stackable) notification to one-time (system) notification -const initialNotification = { - show: false, - autoHide: true, - message: '', - type: 'info', -} -export const notification = proxy(initialNotification) - -export const showNotification = (newNotification: Partial) => { - Object.assign(notification, { show: true, ...newNotification }, initialNotification) -} - // todo restore auto-save on interval for player data! (or implement it in flying squid since there is already auto-save for world) diff --git a/src/index.ts b/src/index.ts index 1c14c7d1f..e10f60a93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,7 +60,6 @@ import { insertActiveModalStack, isGameActive, miscUiState, - notification } from './globalState' @@ -95,6 +94,8 @@ import { downloadSoundsIfNeeded } from './soundSystem' import { ua } from './react/utils' import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls' import { possiblyHandleStateVariable } from './googledrive' +import flyingSquidEvents from './flyingSquidEvents' +import { hideNotification, notificationProxy } from './react/NotificationProvider' window.debug = debug window.THREE = THREE @@ -366,6 +367,7 @@ async function connect (connectOptions: { }) } } + let lastPacket = undefined as string | undefined const onPossibleErrorDisconnect = () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison if (lastPacket && bot?._client && bot._client.state !== 'play') { @@ -373,6 +375,7 @@ async function connect (connectOptions: { } } const handleError = (err) => { + console.error(err) errorAbortController.abort() if (isCypress()) throw err if (miscUiState.gameLoaded) return @@ -471,6 +474,7 @@ async function connect (connectOptions: { setLoadingScreenStatus(newStatus, false, false, true) }) }) + flyingSquidEvents() } let initialLoadingText: string @@ -570,7 +574,6 @@ async function connect (connectOptions: { destroyAll() }) - let lastPacket = undefined as string | undefined const packetBeforePlay = (_, __, ___, fullBuffer) => { lastPacket = fullBuffer.toString() } @@ -676,7 +679,9 @@ async function connect (connectOptions: { } function changeCallback () { - notification.show = false + if (notificationProxy.id === 'pointerlockchange') { + hideNotification() + } if (renderer.xr.isPresenting) return // todo if (!pointerLock.hasPointerLock && activeModalStack.length === 0) { showModal(pauseMenu) diff --git a/src/loadSave.ts b/src/loadSave.ts index 626b06f6b..965b1a7c6 100644 --- a/src/loadSave.ts +++ b/src/loadSave.ts @@ -60,6 +60,7 @@ export const loadSave = async (root = '/world') => { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete forceCachedDataPaths[key] } + // eslint-disable-next-line guard-for-in for (const key in forceRedirectPaths) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete forceRedirectPaths[key] diff --git a/src/menus/notification.js b/src/menus/notification.js deleted file mode 100644 index 0ac6db164..000000000 --- a/src/menus/notification.js +++ /dev/null @@ -1,81 +0,0 @@ -//@ts-check - -// create lit element -const { LitElement, html, css } = require('lit') -const { subscribe } = require('valtio') -const { notification } = require('../globalState') - -class Notification extends LitElement { - static get properties () { - return { - renderHtml: { type: Boolean }, - } - } - - constructor () { - super() - this.renderHtml = false - let timeout - subscribe(notification, () => { - if (timeout) clearTimeout(timeout) - this.requestUpdate() - if (!notification.show) return - this.renderHtml = true - if (!notification.autoHide) return - timeout = setTimeout(() => { - notification.show = false - }, 3000) - }) - } - - render () { - if (!this.renderHtml) return - const show = notification.show && notification.message - return html` -
- ${notification.message} -
- ` - } - - ontransitionend = (event) => { - if (event.propertyName !== 'opacity') return - - if (!notification.show) { - this.renderHtml = false - } - } - - static get styles () { - return css` - .notification { - position: absolute; - bottom: 0; - right: 0; - min-width: 200px; - padding: 10px; - white-space: nowrap; - font-size: 12px; - color: #fff; - text-align: center; - background: #000; - opacity: 0; - transition: opacity 0.3s ease-in-out; - } - - .notification-info { - background: #000; - } - - .notification-error { - background: #d00; - } - - .notification-show { - opacity: 1; - } - ` - } -} - -window.customElements.define('pmui-notification', Notification) diff --git a/src/menus/pause_screen.js b/src/menus/pause_screen.js index ff3226a71..3473dfc56 100644 --- a/src/menus/pause_screen.js +++ b/src/menus/pause_screen.js @@ -4,7 +4,7 @@ const { LitElement, html, css } = require('lit') const { subscribe } = require('valtio') const { subscribeKey } = require('valtio/utils') const { usedServerPathsV1 } = require('flying-squid/dist/lib/modules/world') -const { hideCurrentModal, showModal, miscUiState, notification, openOptionsMenu } = require('../globalState') +const { hideCurrentModal, showModal, miscUiState, openOptionsMenu } = require('../globalState') const { fsState } = require('../loadSave') const { openGithub, setLoadingScreenStatus } = require('../utils') const { disconnect } = require('../flyingSquidUtils') @@ -143,8 +143,6 @@ class PauseScreen extends LitElement { show () { this.focus() - // todo? - notification.show = false } onReturnPress () { diff --git a/src/react/Notification.tsx b/src/react/Notification.tsx new file mode 100644 index 000000000..6ebd49e26 --- /dev/null +++ b/src/react/Notification.tsx @@ -0,0 +1,71 @@ +import { Transition } from 'react-transition-group' +import PixelartIcon from './PixelartIcon' + +// slide up +const startStyle = { opacity: 0, transform: 'translateY(100%)' } +const endExitStyle = { opacity: 0, transform: 'translateY(-100%)' } +const endStyle = { opacity: 1, transform: 'translateY(0)' } + +const stateStyles = { + entering: startStyle, + entered: endStyle, + exiting: endExitStyle, + exited: endExitStyle, +} +const duration = 200 +const basicStyle = { + transition: `${duration}ms ease-in-out all`, +} + +// save pass: login + +export default ({ type = 'message', message, subMessage = '', open, icon = '', action = undefined as (() => void) | undefined }) => { + const isError = type === 'error' + icon ||= isError ? 'alert' : 'message' + + return + {state => { + const addStyles = { ...basicStyle, ...stateStyles[state] } + + return
+ +
+
+ {message} +
+
{subMessage}
+
+
+ }} +
+ +} diff --git a/src/react/NotificationProvider.tsx b/src/react/NotificationProvider.tsx new file mode 100644 index 000000000..2f27a595f --- /dev/null +++ b/src/react/NotificationProvider.tsx @@ -0,0 +1,67 @@ +import React, { useEffect } from 'react' +import { proxy, useSnapshot } from 'valtio' +import Notification from './Notification' + +type NotificationType = React.ComponentProps & { + autoHide: boolean + id: string +} + +// todo stacking +export const notificationProxy = proxy({ + message: '', + open: false, + type: 'message', + subMessage: '', + icon: '', + autoHide: true, + id: '', +} satisfies NotificationType as NotificationType) + +export const showNotification = ( + message: string, + subMessage = '', + isError = false, + icon = '', + action = undefined as (() => void) | undefined, + autoHide = true +) => { + notificationProxy.message = message + notificationProxy.subMessage = subMessage + notificationProxy.type = isError ? 'error' : 'message' + notificationProxy.icon = icon + notificationProxy.open = true + notificationProxy.autoHide = autoHide + notificationProxy.action = action +} +export const hideNotification = () => { + // openNotification('') // reset + notificationProxy.open = false +} + +export default () => { + const { autoHide, message, open, icon, type, subMessage } = useSnapshot(notificationProxy) + + useEffect(() => { + if (autoHide && open) { + setTimeout(() => { + hideNotification() + }, 7000) + } + }, [autoHide, open]) + + // test + // useEffect(() => { + // setTimeout(() => { + // openNotification('test', 'test', false, 'message') + // }, 1000) + // }, []) + + return +} diff --git a/src/react/Singleplayer.tsx b/src/react/Singleplayer.tsx index 1ab2b446d..c00ffc8c8 100644 --- a/src/react/Singleplayer.tsx +++ b/src/react/Singleplayer.tsx @@ -14,6 +14,7 @@ import Tabs from './Tabs' export interface WorldProps { name: string title: string + iconBase64?: string size?: number lastPlayed?: number isFocused?: boolean @@ -22,7 +23,7 @@ export interface WorldProps { onInteraction?(interaction: 'enter' | 'space') } -const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction }: WorldProps) => { +const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconBase64 }: WorldProps) => { const timeRelativeFormatted = useMemo(() => { if (!lastPlayed) return const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) @@ -47,7 +48,7 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction?.(e.code === 'Enter' ? 'enter' : 'space') } }} onDoubleClick={() => onInteraction?.('enter')}> - + world preview
{title}
{timeRelativeFormatted} {detail.slice(-30)}
@@ -123,8 +124,8 @@ export default ({ worldData, onGeneralAction, onWorldAction, activeProvider, set } { worldData - ? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, title, size, lastPlayed, detail }) => ( - { + ? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, size, detail, ...rest }) => ( + { if (interaction === 'enter') onWorldAction('load', name) else if (interaction === 'space') firstButton.current?.focus() }} detail={detail} /> diff --git a/src/react/SingleplayerProvider.tsx b/src/react/SingleplayerProvider.tsx index 349c85c7f..32d175f43 100644 --- a/src/react/SingleplayerProvider.tsx +++ b/src/react/SingleplayerProvider.tsx @@ -30,12 +30,14 @@ const providersEnableFeatures = { calculateSize: true, delete: true, export: true, + icon: true }, google: { calculateSize: false, // TODO delete: false, export: false, + icon: true } } @@ -67,12 +69,22 @@ export const readWorlds = (abortController: AbortController) => { size += stat.size } } + let iconBase64 = '' + if (providersEnableFeatures[provider].icon) { + const iconPath = `${worldsPath}/${folder}/icon.png` + try { + iconBase64 = await fs.promises.readFile(iconPath, 'base64') + } catch { + // ignore + } + } const levelName = levelDat.LevelName as string | undefined return { name: folder, title: levelName ?? folder, lastPlayed: levelDat.LastPlayed && longArrayToNumber(levelDat.LastPlayed), detail: `${levelDat.Version?.Name ?? 'unknown version'}, ${folder}`, + iconBase64, size, } satisfies WorldProps }))).filter((x, i) => { diff --git a/src/react/singleplayer.module.css b/src/react/singleplayer.module.css index 44f9f83a9..3258ba295 100644 --- a/src/react/singleplayer.module.css +++ b/src/react/singleplayer.module.css @@ -36,9 +36,11 @@ } .world_image { height: 100%; - filter: grayscale(1); aspect-ratio: 1; } +.world_image.image_missing { + filter: grayscale(1); +} .world_root.world_focused { border-color: white; } diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 45415578a..1d8ac242c 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -1,6 +1,5 @@ //@ts-check -import { renderToDom } from '@zardoy/react-util' - +import { renderToDom, ErrorBoundary } from '@zardoy/react-util' import { useSnapshot } from 'valtio' import { QRCodeSVG } from 'qrcode.react' import { createPortal } from 'react-dom' @@ -22,9 +21,10 @@ import widgets from './react/widgets' import { useIsWidgetActive } from './react/utils' import GlobalSearchInput from './GlobalSearchInput' import TouchAreasControlsProvider from './react/TouchAreasControlsProvider' +import NotificationProvider from './react/NotificationProvider' -const Portal = ({ children, to }) => { - return createPortal(children, to) +const RobustPortal = ({ children, to }) => { + return createPortal({children}, to) } const DisplayQr = () => { @@ -57,7 +57,7 @@ const InGameUi = () => { if (!gameLoaded) return return <> - + {/* apply scaling */} @@ -65,13 +65,13 @@ const InGameUi = () => { - + - + {/* becaues of z-index */} - + } @@ -90,7 +90,7 @@ const App = () => { return
- + @@ -98,10 +98,20 @@ const App = () => { - + +
} +const PerComponentErrorBoundary = ({ children }) => { + return children.map((child, i) => { + // notfic + const componentNameClean = (child.type.name || child.type.displayName || 'Unknown').replaceAll(/__|_COMPONENT/g, '') + console.error(`UI component ${componentNameClean} crashed!`, componentNameClean, error.message) + return null + }}>{child}) +} + renderToDom(, { strictMode: false, selector: '#react-root', diff --git a/src/soundSystem.ts b/src/soundSystem.ts index 520cd8372..09c87d4b3 100644 --- a/src/soundSystem.ts +++ b/src/soundSystem.ts @@ -3,9 +3,10 @@ import { Vec3 } from 'vec3' import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import type { Block } from 'prismarine-block' -import { miscUiState, showNotification } from './globalState' +import { miscUiState } from './globalState' import { options } from './optionsStorage' import { loadOrPlaySound } from './basicSounds' +import { showNotification } from './react/NotificationProvider' subscribeKey(miscUiState, 'gameLoaded', async () => { if (!miscUiState.gameLoaded) return @@ -21,9 +22,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { if (!soundsMap) { console.warn('No sounds map for version', bot.version, 'supported versions are', Object.keys(allSoundsMap).join(', ')) - showNotification({ - message: 'No sounds map for version ' + bot.version, - }) + showNotification('Warning', 'No sounds map for version ' + bot.version) return } diff --git a/src/texturePack.ts b/src/texturePack.ts index 3af09a873..6ace186b6 100644 --- a/src/texturePack.ts +++ b/src/texturePack.ts @@ -9,7 +9,7 @@ import blocksFileNames from '../generated/blocks.json' import type { BlockStates } from './playerWindows' import { copyFilesAsync, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs' import { setLoadingScreenStatus } from './utils' -import { showNotification } from './globalState' +import { showNotification } from './react/NotificationProvider' export const resourcePackState = proxy({ resourcePackInstalled: false, @@ -96,9 +96,7 @@ export const completeTexturePackInstall = async (name?: string) => { await genTexturePackTextures(viewer.version) } setLoadingScreenStatus(undefined) - showNotification({ - message: 'Texturepack installed!', - }) + showNotification('Texturepack installed') await updateTexturePackInstalledState() } diff --git a/src/utils.ts b/src/utils.ts index e8658acfe..61ceeeb80 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ -import { hideModal, isGameActive, miscUiState, notification, showModal } from './globalState' +import { hideModal, isGameActive, miscUiState, showModal } from './globalState' import { options } from './optionsStorage' import { appStatusState } from './react/AppStatusProvider' +import { notificationProxy, showNotification } from './react/NotificationProvider' export const goFullscreen = async (doToggle = false) => { if (!document.fullscreenElement) { @@ -32,8 +33,8 @@ export const pointerLock = { void goFullscreen() } const displayBrowserProblem = () => { - notification.show = true - notification.message = navigator['keyboard'] ? 'Browser Limitation: Click on screen, enable Auto Fullscreen or F11' : 'Browser Limitation: Click on screen or use fullscreen in Chrome' + showNotification('Browser Delay Limitation', navigator['keyboard'] ? 'Click on screen, enable Auto Fullscreen or F11' : 'Click on screen or use fullscreen in Chrome') + notificationProxy.id = 'pointerlockchange' } if (!(document.fullscreenElement && navigator['keyboard']) && this.justHitEscape) { displayBrowserProblem()