mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-08-18 13:36:47 +00:00
Merge 07b152bdfc into 94a7be7c11
This commit is contained in:
commit
d3fa1a50d3
12 changed files with 460 additions and 34 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
143
src/App/WebUpdateScreen/WebUpdateScreen.less
Normal file
143
src/App/WebUpdateScreen/WebUpdateScreen.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/App/WebUpdateScreen/WebUpdateScreen.tsx
Normal file
62
src/App/WebUpdateScreen/WebUpdateScreen.tsx
Normal 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;
|
||||
3
src/App/WebUpdateScreen/index.ts
Normal file
3
src/App/WebUpdateScreen/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import WebUpdateScreen from './WebUpdateScreen';
|
||||
|
||||
export default WebUpdateScreen;
|
||||
185
src/App/WebUpdateScreen/useServiceWorkerUpdater.ts
Normal file
185
src/App/WebUpdateScreen/useServiceWorkerUpdater.ts
Normal 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;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.updater-banner {
|
||||
.update-banner {
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -43,4 +43,4 @@
|
|||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/components/UpdateBanner/UpdateBanner.tsx
Normal file
37
src/components/UpdateBanner/UpdateBanner.tsx
Normal 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;
|
||||
3
src/components/UpdateBanner/index.ts
Normal file
3
src/components/UpdateBanner/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import UpdateBanner from './UpdateBanner';
|
||||
|
||||
export default UpdateBanner;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
15
src/index.js
15
src/index.js
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue