This commit is contained in:
Timothy Z. 2026-07-27 15:39:33 +03:00 committed by GitHub
commit d3fa1a50d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 460 additions and 34 deletions

View file

@ -49,7 +49,8 @@
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#0f290a5e5d17c295083ab0162e0b9054c07b123b",
"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",

View file

@ -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

View file

@ -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 (
<div className={className}>
<Transition when={visible && !isPlayer} name={'slide-up'}>
<div className={styles['updater-banner']}>
<div className={styles['label']}>
{ t('UPDATER_TITLE') }
</div>
<Button className={styles['button']} onClick={onInstallClick}>
{ t('UPDATER_INSTALL_BUTTON') }
</Button>
<Button className={styles['close']} onClick={hide}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
</Transition>
</div>
<UpdateBanner
className={className}
visible={visible && isPlayer === null}
label={t('UPDATER_TITLE')}
actionLabel={t('UPDATER_INSTALL_BUTTON')}
closeLabel={t('BUTTON_CLOSE')}
onAction={onInstallClick}
onClose={hide}
/>
);
};

View file

@ -0,0 +1,143 @@
// Copyright (C) 2017-2026 Smart code 203358507
@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;
bottom: 0;
left: 0;
right: 0;
z-index: 101;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 2rem;
background: linear-gradient(41deg, var(--primary-background-color) 0%, var(--secondary-background-color) 100%);
color: var(--primary-foreground-color);
text-align: center;
animation: 0.2s ease-out fade-in;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes pulse {
0% {
opacity: 0.55;
}
100% {
opacity: 1;
}
}
@keyframes progress {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
}
.logo {
flex: none;
width: 8rem;
height: 8rem;
object-fit: contain;
object-position: center;
animation: 1s linear infinite alternate pulse;
}
.title {
max-width: 42rem;
margin-top: 0.5rem;
font-size: 1.875rem;
font-weight: 500;
line-height: 1.25;
color: var(--primary-foreground-color);
overflow-wrap: anywhere;
}
.progress {
position: relative;
width: 18rem;
max-width: 100%;
height: 0.4rem;
margin-top: 0.25rem;
border-radius: var(--border-radius);
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: inherit;
background-color: var(--primary-foreground-color);
opacity: 0.2;
}
}
.progress-value {
position: relative;
z-index: 1;
width: 40%;
height: 100%;
border-radius: inherit;
background-color: var(--primary-accent-color);
animation: 1.2s ease-in-out infinite progress;
}
@media only screen and (max-width: 30rem) {
gap: 1rem;
padding: 1.5rem;
.logo {
width: 6.5rem;
height: 6.5rem;
}
.title {
font-size: 1.4rem;
}
.progress {
width: 15rem;
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
.logo {
animation: none;
}
.progress-value {
width: 100%;
opacity: 0.65;
animation: none;
}
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch } from 'react-router';
import { UpdateBanner } from 'stremio/components';
import useServiceWorkerUpdater from './useServiceWorkerUpdater';
import styles from './WebUpdateScreen.less';
const WebUpdateScreen = () => {
const { t } = useTranslation();
const { state, dismissed, applyUpdate, dismissUpdate } = useServiceWorkerUpdater();
const isPlayer = useMatch('/player/*');
const autoApply = state.status === 'ready' && state.autoApply;
const promptVisible =
!dismissed &&
isPlayer === null &&
(state.status === 'ready' || state.status === 'reload-ready' || state.status === 'failed');
useEffect(() => {
if (autoApply && isPlayer === null) {
applyUpdate();
}
}, [applyUpdate, autoApply, isPlayer]);
if (state.status === 'applying') {
return (
<div
className={styles['web-update-screen']}
role={'status'}
aria-live={'polite'}
aria-busy={true}
>
<img
className={styles['logo']}
src={require('/assets/images/stremio_symbol.png')}
alt={''}
/>
<div className={styles['title']}>
{t('UPDATER_TITLE')}
</div>
<div className={styles['progress']} aria-hidden={true}>
<div className={styles['progress-value']} />
</div>
</div>
);
}
return (
<UpdateBanner
className={styles['web-update-banner']}
visible={promptVisible}
label={t('UPDATER_TITLE')}
actionLabel={state.status === 'failed' ? t('TRY_AGAIN') : t('RELOAD_UI')}
closeLabel={t('BUTTON_CLOSE')}
onAction={applyUpdate}
onClose={dismissUpdate}
/>
);
};
export default WebUpdateScreen;

View file

@ -0,0 +1,3 @@
import WebUpdateScreen from './WebUpdateScreen';
export default WebUpdateScreen;

View file

@ -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<typeof setTimeout> | 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<UpdaterState>({ status: 'idle' });
const [dismissed, setDismissed] = useState(false);
const runtimeRef = useRef<Runtime>({
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;

View file

@ -1,4 +1,4 @@
.updater-banner {
.update-banner {
height: 4rem;
display: flex;
align-items: center;
@ -43,4 +43,4 @@
height: 2rem;
}
}
}
}

View file

@ -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) => (
<div className={className}>
<Transition when={visible} name={'slide-up'}>
<div className={styles['update-banner']} role={'status'}>
<div className={styles['label']}>
{label}
</div>
<Button className={styles['button']} onClick={onAction}>
{actionLabel}
</Button>
<Button className={styles['close']} title={closeLabel} onClick={onClose}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
</Transition>
</div>
);
export default UpdateBanner;

View file

@ -0,0 +1,3 @@
import UpdateBanner from './UpdateBanner';
export default UpdateBanner;

View file

@ -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
};

View file

@ -18,6 +18,7 @@ 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 } = require('./common');
@ -48,19 +49,13 @@ root.render(
<CoreProvider appInfo={appInfo}>
<FileDropProvider>
<HashRouter>
<App />
<>
<WebUpdateScreen />
<App />
</>
</HashRouter>
</FileDropProvider>
</CoreProvider>
</PlatformProvider>
</React.StrictMode>
);
if (process.env.NODE_ENV === 'production' && process.env.SERVICE_WORKER_DISABLED !== 'true' && process.env.SERVICE_WORKER_DISABLED !== true && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('service-worker.js')
.catch((registrationError) => {
console.error('SW registration failed: ', registrationError);
});
});
}