diff --git a/package.json b/package.json index 91a8414cc..1cb4aa529 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6", "stremio-translations": "github:Stremio/stremio-translations#5140f0db28f61b3f80939bed36df93512ca3e841", "url": "0.11.4", - "use-long-press": "^3.3.0" + "use-long-press": "^3.3.0", + "workbox-window": "7.4.1" }, "devDependencies": { "@babel/core": "7.29.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 928dd9aed..d7b88a248 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: use-long-press: specifier: ^3.3.0 version: 3.3.0(react@18.3.1) + workbox-window: + specifier: 7.4.1 + version: 7.4.1 devDependencies: '@babel/core': specifier: 7.29.0 diff --git a/src/App/App.js b/src/App/App.js index 9666b9ea4..1ad2a0980 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -12,7 +12,6 @@ const ServicesToaster = require('./ServicesToaster'); const SearchParamsHandler = require('./SearchParamsHandler'); const DeepLinkHandler = require('./DeepLinkHandler'); const { default: UpdaterBanner } = require('./UpdaterBanner'); -const { default: WebUpdateScreen } = require('./WebUpdateScreen'); const { default: ShortcutsModal } = require('./ShortcutsModal'); const { default: GamepadModal } = require('./GamepadModal'); const styles = require('./styles'); @@ -199,7 +198,6 @@ const App = () => { - diff --git a/src/App/UpdaterBanner/UpdaterBanner.tsx b/src/App/UpdaterBanner/UpdaterBanner.tsx index 0a5dabc85..741b5f9f9 100644 --- a/src/App/UpdaterBanner/UpdaterBanner.tsx +++ b/src/App/UpdaterBanner/UpdaterBanner.tsx @@ -1,10 +1,8 @@ -import React, { useEffect } from 'react'; -import Icon from '@stremio/stremio-icons/react'; +import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useMatch } from 'react-router'; import { useBinaryState, usePlatform } from 'stremio/common'; -import { Button, Transition } from 'stremio/components'; -import styles from './UpdaterBanner.less'; +import { UpdateBanner } from 'stremio/components'; type Props = { className: string, @@ -16,9 +14,9 @@ const UpdaterBanner = ({ className }: Props) => { const [visible, show, hide] = useBinaryState(false); const isPlayer = useMatch('/player/*'); - const onInstallClick = () => { + const onInstallClick = useCallback(() => { shell.send('autoupdater-notif-clicked'); - }; + }, [shell]); useEffect(() => { shell.on('autoupdater-show-notif', show); @@ -29,21 +27,15 @@ const UpdaterBanner = ({ className }: Props) => { }, []); return ( -
- -
-
- { t('UPDATER_TITLE') } -
- - -
-
-
+ ); }; diff --git a/src/App/WebUpdateScreen/WebUpdateScreen.less b/src/App/WebUpdateScreen/WebUpdateScreen.less index 614cda949..17866fa34 100644 --- a/src/App/WebUpdateScreen/WebUpdateScreen.less +++ b/src/App/WebUpdateScreen/WebUpdateScreen.less @@ -2,6 +2,14 @@ @import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; +.web-update-banner { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 101; +} + .web-update-screen { position: fixed; top: 0; @@ -30,9 +38,9 @@ } } - @keyframes flash { + @keyframes pulse { 0% { - opacity: 0.4; + opacity: 0.55; } 100% { @@ -40,13 +48,13 @@ } } - @keyframes progress-fill { + @keyframes progress { 0% { - transform: scaleX(0); + transform: translateX(-100%); } 100% { - transform: scaleX(1); + transform: translateX(250%); } } @@ -56,7 +64,7 @@ height: 8rem; object-fit: contain; object-position: center; - animation: 1s linear infinite alternate flash; + animation: 1s linear infinite alternate pulse; } .title { @@ -76,6 +84,7 @@ height: 0.4rem; margin-top: 0.25rem; border-radius: var(--border-radius); + overflow: hidden; &::before { content: ''; @@ -93,12 +102,11 @@ .progress-value { position: relative; z-index: 1; - width: 100%; + width: 40%; height: 100%; border-radius: inherit; background-color: var(--primary-accent-color); - transform-origin: left; - animation: 2s ease-out forwards progress-fill; + animation: 1.2s ease-in-out infinite progress; } @media only screen and (max-width: 30rem) { @@ -118,4 +126,18 @@ width: 15rem; } } + + @media (prefers-reduced-motion: reduce) { + animation: none; + + .logo { + animation: none; + } + + .progress-value { + width: 100%; + opacity: 0.65; + animation: none; + } + } } diff --git a/src/App/WebUpdateScreen/WebUpdateScreen.tsx b/src/App/WebUpdateScreen/WebUpdateScreen.tsx index fbf4bb4ec..c76a2e0fb 100644 --- a/src/App/WebUpdateScreen/WebUpdateScreen.tsx +++ b/src/App/WebUpdateScreen/WebUpdateScreen.tsx @@ -3,40 +3,59 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useMatch } from 'react-router'; -import { useServiceWorkerUpdater, useTimeout } from 'stremio/common'; +import { UpdateBanner } from 'stremio/components'; +import useServiceWorkerUpdater from './useServiceWorkerUpdater'; import styles from './WebUpdateScreen.less'; -const APPLY_DELAY = 2000; - const WebUpdateScreen = () => { const { t } = useTranslation(); - const { updateReady, applyUpdate } = useServiceWorkerUpdater(); + const { state, dismissed, applyUpdate, dismissUpdate } = useServiceWorkerUpdater(); const isPlayer = useMatch('/player/*'); - const timeout = useTimeout(APPLY_DELAY); - const visible = updateReady && isPlayer === null; + const autoApply = state.status === 'ready' && state.autoApply; + const promptVisible = + !dismissed && + isPlayer === null && + (state.status === 'ready' || state.status === 'reload-ready' || state.status === 'failed'); useEffect(() => { - if (visible) { - timeout.start(applyUpdate); - - return timeout.cancel; + if (autoApply && isPlayer === null) { + applyUpdate(); } - }, [visible]); + }, [applyUpdate, autoApply, isPlayer]); - if (!visible) { - return null; + if (state.status === 'applying') { + return ( +
+ {''} +
+ {t('UPDATER_TITLE')} +
+
+
+
+
+ ); } return ( -
- {' -
- {t('UPDATER_TITLE')} -
-
-
-
-
+ ); }; diff --git a/src/App/WebUpdateScreen/useServiceWorkerUpdater.ts b/src/App/WebUpdateScreen/useServiceWorkerUpdater.ts new file mode 100644 index 000000000..b9778f14f --- /dev/null +++ b/src/App/WebUpdateScreen/useServiceWorkerUpdater.ts @@ -0,0 +1,185 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Workbox, type WorkboxLifecycleEvent, type WorkboxLifecycleWaitingEvent } from 'workbox-window'; +import { usePlatform } from 'stremio/common'; +import usePWA from 'stremio/common/usePWA'; + +const UPDATE_CHECK_INTERVAL = 30 * 60 * 1000; +const APPLY_TIMEOUT = 15 * 1000; + +type UpdaterState = + | { status: 'idle' } + | { status: 'ready', autoApply: boolean } + | { status: 'applying' } + | { status: 'reload-ready' } + | { status: 'failed' }; + +type Runtime = { + workbox: Workbox | null, + applying: boolean, + timeout: ReturnType | null, + checkForUpdate: (() => void) | null, +}; + +const clearApplyTimeout = (runtime: Runtime) => { + if (runtime.timeout !== null) { + clearTimeout(runtime.timeout); + runtime.timeout = null; + } +}; + +const useServiceWorkerUpdater = () => { + const { shell } = usePlatform(); + const [isIOSPWA, isStandalonePWA] = usePWA(); + const [state, setState] = useState({ status: 'idle' }); + const [dismissed, setDismissed] = useState(false); + const runtimeRef = useRef({ + workbox: null, + applying: false, + timeout: null, + checkForUpdate: null, + }); + const appLike = shell.active || Boolean(isIOSPWA) || Boolean(isStandalonePWA); + + const dismissUpdate = useCallback(() => { + setDismissed(true); + }, []); + + const applyUpdate = useCallback(() => { + if (state.status === 'reload-ready') { + window.location.reload(); + return; + } + + const runtime = runtimeRef.current; + if ( + runtime.applying || + runtime.workbox === null || + (state.status !== 'ready' && state.status !== 'failed') + ) { + return; + } + + runtime.applying = true; + clearApplyTimeout(runtime); + setState({ status: 'applying' }); + runtime.timeout = setTimeout(() => { + runtime.timeout = null; + runtime.applying = false; + setState({ status: 'failed' }); + }, APPLY_TIMEOUT); + runtime.workbox.messageSkipWaiting(); + }, [state.status]); + + useEffect(() => { + const serviceWorkerDisabled = process.env.SERVICE_WORKER_DISABLED as string | boolean | undefined; + if ( + process.env.NODE_ENV !== 'production' || + serviceWorkerDisabled === 'true' || + serviceWorkerDisabled === true || + !('serviceWorker' in navigator) + ) { + return; + } + + const runtime = runtimeRef.current; + const workbox = new Workbox('service-worker.js'); + let registered = false; + let lastUpdateCheck = Date.now(); + runtime.workbox = workbox; + + const onWaiting = (event: WorkboxLifecycleWaitingEvent) => { + setDismissed(false); + setState({ + status: 'ready', + autoApply: appLike && event.wasWaitingBeforeRegister === true, + }); + }; + const onControlling = (event: WorkboxLifecycleEvent) => { + if (!event.isUpdate) { + return; + } + + clearApplyTimeout(runtime); + if (runtime.applying) { + window.location.reload(); + } else { + setDismissed(false); + setState({ status: 'reload-ready' }); + } + }; + const checkForUpdate = () => { + const now = Date.now(); + if ( + !registered || + navigator.serviceWorker.controller === null || + now - lastUpdateCheck < UPDATE_CHECK_INTERVAL + ) { + return; + } + + lastUpdateCheck = now; + workbox.update().catch((error) => { + console.error('SW update check failed: ', error); + }); + }; + runtime.checkForUpdate = checkForUpdate; + + workbox.addEventListener('waiting', onWaiting); + workbox.addEventListener('controlling', onControlling); + workbox.register() + .then(() => { + registered = true; + }) + .catch((error) => { + console.error('SW registration failed: ', error); + }); + + return () => { + registered = false; + workbox.removeEventListener('waiting', onWaiting); + workbox.removeEventListener('controlling', onControlling); + clearApplyTimeout(runtime); + runtime.applying = false; + runtime.checkForUpdate = null; + if (runtime.workbox === workbox) { + runtime.workbox = null; + } + }; + }, [appLike]); + + useEffect(() => { + const onForeground = () => { + setDismissed(false); + runtimeRef.current.checkForUpdate?.(); + }; + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + onForeground(); + } + }; + const onShellVisibilityChange = ({ visible }: { visible?: boolean }) => { + if (visible) { + onForeground(); + } + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + shell.on('win-visibility-changed', onShellVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + shell.off('win-visibility-changed', onShellVisibilityChange); + }; + }, [shell]); + + return { + state, + dismissed, + applyUpdate, + dismissUpdate, + }; +}; + +export default useServiceWorkerUpdater; diff --git a/src/common/index.js b/src/common/index.js index 54e125a70..bca92c177 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -31,7 +31,6 @@ const useTorrent = require('./useTorrent'); const useTranslate = require('./useTranslate'); const { default: useOrientation } = require('./useOrientation'); const { default: useLanguageSorting } = require('./useLanguageSorting'); -const { registerServiceWorker, useServiceWorkerUpdater } = require('./useServiceWorkerUpdater'); module.exports = { FileDropProvider, @@ -77,6 +76,4 @@ module.exports = { useTranslate, useOrientation, useLanguageSorting, - registerServiceWorker, - useServiceWorkerUpdater, }; diff --git a/src/common/useServiceWorkerUpdater.ts b/src/common/useServiceWorkerUpdater.ts deleted file mode 100644 index 14ee82e70..000000000 --- a/src/common/useServiceWorkerUpdater.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (C) 2017-2026 Smart code 203358507 - -import { useSyncExternalStore } from 'react'; - -type Snapshot = { - updateReady: boolean, - reloadPending: boolean, -}; - -const INITIAL_SNAPSHOT: Snapshot = { - updateReady: false, - reloadPending: false, -}; - -let snapshot = INITIAL_SNAPSHOT; -let registration: ServiceWorkerRegistration | null = null; -let waitingWorker: ServiceWorker | null = null; -let promptReady = false; -let reloadPending = false; -let reloading = false; -let applyingUpdate = false; -let promptOnNextUpdateFound = false; -let promptOnNextUpdateFoundTimeout: ReturnType | null = null; - -const listeners = new Set<() => void>(); - -const subscribe = (listener: () => void) => { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; -}; - -const getSnapshot = () => snapshot; - -const emitSnapshot = () => { - const nextSnapshot = { - updateReady: promptReady && (waitingWorker !== null || reloadPending), - reloadPending, - }; - - if (snapshot.updateReady !== nextSnapshot.updateReady || snapshot.reloadPending !== nextSnapshot.reloadPending) { - snapshot = nextSnapshot; - listeners.forEach((listener) => listener()); - } -}; - -const clearPromptOnNextUpdateFound = () => { - promptOnNextUpdateFound = false; - - if (promptOnNextUpdateFoundTimeout !== null) { - clearTimeout(promptOnNextUpdateFoundTimeout); - promptOnNextUpdateFoundTimeout = null; - } -}; - -const markPromptOnNextUpdateFound = () => { - clearPromptOnNextUpdateFound(); - promptOnNextUpdateFound = true; - promptOnNextUpdateFoundTimeout = setTimeout(clearPromptOnNextUpdateFound, 60000); -}; - -const consumePromptOnNextUpdateFound = () => { - const shouldPrompt = promptOnNextUpdateFound; - clearPromptOnNextUpdateFound(); - - return shouldPrompt; -}; - -const reloadPage = () => { - if (reloading) { - return; - } - - reloading = true; - window.location.reload(); -}; - -const setWaitingWorker = (worker: ServiceWorker, shouldPrompt: boolean) => { - waitingWorker = worker; - promptReady = shouldPrompt || promptReady; - emitSnapshot(); -}; - -const trackInstalling = (worker: ServiceWorker | null, shouldPrompt: boolean) => { - if (worker === null) { - return; - } - - const onStateChange = () => { - if (worker.state === 'installed' && navigator.serviceWorker.controller !== null) { - setWaitingWorker(worker, shouldPrompt); - } - }; - - worker.addEventListener('statechange', onStateChange); - onStateChange(); -}; - -const checkForUpdate = () => { - if (registration === null || navigator.serviceWorker.controller === null) { - return; - } - - markPromptOnNextUpdateFound(); - registration.update().catch((error) => { - clearPromptOnNextUpdateFound(); - console.error('SW update check failed: ', error); - }); -}; - -const showPendingUpdate = () => { - if (waitingWorker !== null || reloadPending) { - promptReady = true; - emitSnapshot(); - return true; - } - - return false; -}; - -const applyUpdate = () => { - if (reloadPending) { - reloadPage(); - return; - } - - if (waitingWorker !== null) { - applyingUpdate = true; - waitingWorker.postMessage({ type: 'SKIP_WAITING' }); - } -}; - -const registerServiceWorker = async () => { - let hadController = navigator.serviceWorker.controller !== null; - - navigator.serviceWorker.addEventListener('controllerchange', () => { - if (!hadController) { - hadController = true; - return; - } - - waitingWorker = null; - - if (applyingUpdate) { - reloadPage(); - return; - } - - reloadPending = true; - promptReady = false; - emitSnapshot(); - }); - - try { - registration = await navigator.serviceWorker.register('service-worker.js'); - - registration.addEventListener('updatefound', () => { - trackInstalling(registration?.installing ?? null, consumePromptOnNextUpdateFound()); - }); - - trackInstalling(registration.installing, true); - - if (registration.waiting !== null && navigator.serviceWorker.controller !== null) { - setWaitingWorker(registration.waiting, true); - } else { - checkForUpdate(); - } - - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && !showPendingUpdate()) { - checkForUpdate(); - } - }); - } catch (error) { - console.error('SW registration failed: ', error); - } -}; - -const useServiceWorkerUpdater = () => { - const updaterSnapshot = useSyncExternalStore(subscribe, getSnapshot); - - return { - ...updaterSnapshot, - applyUpdate, - }; -}; - -export { - registerServiceWorker, - useServiceWorkerUpdater, -}; diff --git a/src/App/UpdaterBanner/UpdaterBanner.less b/src/components/UpdateBanner/UpdateBanner.less similarity index 98% rename from src/App/UpdaterBanner/UpdaterBanner.less rename to src/components/UpdateBanner/UpdateBanner.less index 9928fb493..a88b8c2bd 100644 --- a/src/App/UpdaterBanner/UpdaterBanner.less +++ b/src/components/UpdateBanner/UpdateBanner.less @@ -1,4 +1,4 @@ -.updater-banner { +.update-banner { height: 4rem; display: flex; align-items: center; @@ -43,4 +43,4 @@ height: 2rem; } } -} \ No newline at end of file +} diff --git a/src/components/UpdateBanner/UpdateBanner.tsx b/src/components/UpdateBanner/UpdateBanner.tsx new file mode 100644 index 000000000..88cfb5ebd --- /dev/null +++ b/src/components/UpdateBanner/UpdateBanner.tsx @@ -0,0 +1,37 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import React from 'react'; +import Icon from '@stremio/stremio-icons/react'; +import Button from '../Button'; +import Transition from '../Transition'; +import styles from './UpdateBanner.less'; + +type Props = { + className?: string, + visible: boolean, + label: string, + actionLabel: string, + closeLabel: string, + onAction: () => void, + onClose: () => void, +}; + +const UpdateBanner = ({ className, visible, label, actionLabel, closeLabel, onAction, onClose }: Props) => ( +
+ +
+
+ {label} +
+ + +
+
+
+); + +export default UpdateBanner; diff --git a/src/components/UpdateBanner/index.ts b/src/components/UpdateBanner/index.ts new file mode 100644 index 000000000..099937b07 --- /dev/null +++ b/src/components/UpdateBanner/index.ts @@ -0,0 +1,3 @@ +import UpdateBanner from './UpdateBanner'; + +export default UpdateBanner; diff --git a/src/components/index.ts b/src/components/index.ts index 75400b0dd..a8006ce17 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -29,6 +29,7 @@ import ShortcutsGroup from './ShortcutsGroup'; import TextInput from './TextInput'; import Toggle from './Toggle'; import Transition from './Transition'; +import UpdateBanner from './UpdateBanner'; import Video from './Video'; import ActionsGroup from './ActionsGroup'; @@ -65,6 +66,7 @@ export { TextInput, Toggle, Transition, + UpdateBanner, Video, ActionsGroup }; diff --git a/src/index.js b/src/index.js index 16c32cb03..0b207526b 100755 --- a/src/index.js +++ b/src/index.js @@ -18,8 +18,9 @@ const i18n = require('i18next'); const { initReactI18next } = require('react-i18next'); const stremioTranslations = require('stremio-translations'); const App = require('./App'); +const { default: WebUpdateScreen } = require('./App/WebUpdateScreen'); const { CoreProvider } = require('./core'); -const { FileDropProvider, PlatformProvider, registerServiceWorker } = require('./common'); +const { FileDropProvider, PlatformProvider } = require('./common'); const translations = Object.fromEntries(Object.entries(stremioTranslations()).map(([key, value]) => [key, { translation: value @@ -48,14 +49,13 @@ root.render( - + <> + + + ); - -if (process.env.NODE_ENV === 'production' && process.env.SERVICE_WORKER_DISABLED !== 'true' && process.env.SERVICE_WORKER_DISABLED !== true && 'serviceWorker' in navigator) { - window.addEventListener('load', registerServiceWorker); -} diff --git a/webpack.config.js b/webpack.config.js index 1c8106697..1ea934e03 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -224,7 +224,7 @@ module.exports = (env, argv) => ({ new WorkboxPlugin.GenerateSW({ maximumFileSizeToCacheInBytes: 20000000, clientsClaim: true, - skipWaiting: false + skipWaiting: true }), new CopyWebpackPlugin({ patterns: [