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: [