Merge branch 'development' into feat/react-router

This commit is contained in:
Timothy Z. 2025-06-24 22:21:58 +03:00 committed by GitHub
commit ec4c6ef512
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 109 additions and 106 deletions

View file

@ -13,7 +13,7 @@ jobs:
steps:
# Auto assign PR to author
- name: Auto Assign PR to Author
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@ -31,6 +31,7 @@ jobs:
# Dynamic labeling based on PR/Issue title
- name: Label PRs and Issues
if: github.actor != 'dependabot[bot]'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v7

View file

@ -19,7 +19,7 @@ jobs:
- name: Zip build artifact
run: zip -r stremio-web.zip ./build
- name: Upload build artifact to GitHub release assets
uses: svenstaro/upload-release-action@2.9.0
uses: svenstaro/upload-release-action@2.10.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: stremio-web.zip

View file

@ -13,6 +13,7 @@ const Transition = ({ children, when, name }: Props) => {
const [state, setState] = useState('enter');
const [active, setActive] = useState(false);
const [transitionEnded, setTransitionEnded] = useState(false);
const callbackRef = useCallback((element: HTMLElement | null) => {
setElement(element);
@ -30,12 +31,14 @@ const Transition = ({ children, when, name }: Props) => {
}, [name, state, active, children]);
const onTransitionEnd = useCallback(() => {
setTransitionEnded(true);
state === 'exit' && setMounted(false);
}, [state]);
useEffect(() => {
setState(when ? 'enter' : 'exit');
when && setMounted(true);
setTransitionEnded(false);
}, [when]);
useEffect(() => {
@ -53,6 +56,7 @@ const Transition = ({ children, when, name }: Props) => {
mounted && cloneElement(children, {
ref: callbackRef,
className,
transitionEnded
})
);
};

View file

@ -13,10 +13,11 @@ const useProfile = require('stremio/common/useProfile');
const VideoPlaceholder = require('./VideoPlaceholder');
const styles = require('./styles');
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
const Video = React.forwardRef(({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }, ref) => {
const routeFocused = useRouteFocused();
const profile = useProfile();
const navigate = useNavigate();
const { t } = useTranslation();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const popupLabelOnMouseUp = React.useCallback((event) => {
if (!event.nativeEvent.togglePopupPrevented) {
@ -73,7 +74,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) {
const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched;
return (
<Button {...props} className={classnames(className, styles['video-container'])} title={title}>
<Button {...props} className={classnames(className, styles['video-container'])} title={title} ref={ref}>
{
typeof thumbnail === 'string' && thumbnail.length > 0 ?
<div className={styles['thumbnail-container']}>
@ -188,7 +189,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
renderMenu={renderMenu}
/>
);
};
});
Video.Placeholder = VideoPlaceholder;

View file

@ -127,17 +127,22 @@ const Player = () => {
}, []);
const onEnded = React.useCallback(() => {
// here we need to explicitly check for isNavigating.current
// the ended event can be called multiple times by MPV inside Shell
if (isNavigating.current) {
return;
}
ended();
if (player.nextVideo !== null) {
onNextVideoRequested();
if (window.playerNextVideo !== null) {
nextVideo();
const deepLinks = window.playerNextVideo.deepLinks;
handleNextVideoNavigation(deepLinks);
} else {
navigate(-1);
}
}, [player.nextVideo, onNextVideoRequested]);
}, []);
const onError = React.useCallback((error) => {
console.error('Player', error);
@ -417,6 +422,14 @@ const Player = () => {
closeNextVideoPopup();
}
}
if (player.nextVideo) {
// This is a workaround for the fact that when we call onEnded nextVideo from the player is already set to null since core unloads the stream
// we explicitly set it to a global variable so we can access it in the onEnded function
// this is not a good solution but it works for now
window.playerNextVideo = player.nextVideo;
} else {
window.playerNextVideo = null;
}
}, [player.nextVideo, video.state.time, video.state.duration]);
React.useEffect(() => {
@ -459,6 +472,9 @@ const Player = () => {
defaultSubtitlesSelected.current = false;
defaultAudioTrackSelected.current = false;
nextVideoPopupDismissed.current = false;
// we need a timeout here to make sure that previous page unloads and the new one loads
// avoiding race conditions and flickering
setTimeout(() => isNavigating.current = false, 1000);
}, [video.state.stream]);
React.useEffect(() => {
@ -844,6 +860,7 @@ const Player = () => {
metaItem={player.metaItem?.content}
seriesInfo={player.seriesInfo}
closeSideDrawer={closeSideDrawer}
selected={player.selected?.streamRequest.path.id}
/>
</Transition>
{

View file

@ -1,6 +1,6 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useMemo, useCallback, useState, forwardRef, memo } from 'react';
import React, { useMemo, useCallback, useState, forwardRef, memo, useRef, useEffect } from 'react';
import classNames from 'classnames';
import Icon from '@stremio/stremio-icons/react';
import { useServices } from 'stremio/services';
@ -14,11 +14,14 @@ type Props = {
seriesInfo: SeriesInfo;
metaItem: MetaItem;
closeSideDrawer: () => void;
selected: string;
transitionEnded: boolean;
};
const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, className, closeSideDrawer, ...props }: Props, ref) => {
const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, className, closeSideDrawer, selected, transitionEnded, ...props }: Props, ref) => {
const { core } = useServices();
const [season, setSeason] = useState<number>(seriesInfo?.season);
const selectedVideoRef = useRef<HTMLDivElement>(null);
const metaItem = useMemo(() => {
return seriesInfo ?
{
@ -75,6 +78,14 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
event.stopPropagation();
};
const getSelectedRef = useCallback((video: Video) => {
return video.id === selected ? selectedVideoRef : null;
}, [selected]);
useEffect(() => {
transitionEnded && selectedVideoRef?.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [transitionEnded]);
return (
<div ref={ref} className={classNames(styles['side-drawer'], className)} onMouseDown={onMouseDown}>
<div className={styles['close-button']} onClick={closeSideDrawer}>
@ -120,6 +131,7 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
scheduled={video.scheduled}
onMarkVideoAsWatched={onMarkVideoAsWatched}
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
ref={getSelectedRef(video)}
/>
))}
</div>

View file

@ -11,21 +11,6 @@ function Shell() {
const events = new EventEmitter();
function onTransportInit() {
active = true;
error = null;
starting = false;
onStateChanged();
}
function onTransportInitError(err) {
console.error(err);
active = false;
error = new Error(err);
starting = false;
onStateChanged();
transport = null;
}
function onStateChanged() {
events.emit('stateChanged');
}
@ -68,9 +53,22 @@ function Shell() {
active = false;
starting = true;
transport = new ShellTransport();
transport.on('init', onTransportInit);
transport.on('init-error', onTransportInitError);
try {
transport = new ShellTransport();
active = true;
error = null;
starting = false;
onStateChanged();
} catch (e) {
console.error(e);
active = false;
error = new Error(e);
starting = false;
onStateChanged();
transport = null;
}
onStateChanged();
};
this.stop = function() {

View file

@ -2,9 +2,6 @@
const EventEmitter = require('eventemitter3');
let shellAvailable = false;
const shellEvents = new EventEmitter();
const QtMsgTypes = {
signal: 1,
propertyUpdate: 2,
@ -19,27 +16,6 @@ const QtMsgTypes = {
};
const QtObjId = 'transport'; // the ID of our transport object
window.initShellComm = function () {
delete window.initShellComm;
shellEvents.emit('availabilityChanged');
};
const initialize = () => {
if(!window.qt) return Promise.reject('Qt API not found');
return new Promise((resolve) => {
function onShellAvailabilityChanged() {
shellEvents.off('availabilityChanged', onShellAvailabilityChanged);
shellAvailable = true;
resolve();
}
if (shellAvailable) {
onShellAvailabilityChanged();
} else {
shellEvents.on('availabilityChanged', onShellAvailabilityChanged);
}
});
};
function ShellTransport() {
const events = new EventEmitter();
@ -47,66 +23,60 @@ function ShellTransport() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const shell = this;
initialize()
.then(() => {
const transport = window.qt && window.qt.webChannelTransport;
if (!transport) throw 'no viable transport found (qt.webChannelTransport)';
const transport = window.qt && window.qt.webChannelTransport;
if (!transport) throw 'no viable transport found (qt.webChannelTransport)';
let id = 0;
function send(msg) {
msg.id = id++;
transport.send(JSON.stringify(msg));
let id = 0;
function send(msg) {
msg.id = id++;
transport.send(JSON.stringify(msg));
}
transport.onmessage = function (message) {
const msg = JSON.parse(message.data);
if (msg.id === 0) {
const obj = msg.data[QtObjId];
obj.properties.slice(1).forEach(function (prop) {
shell.props[prop[1]] = prop[3];
});
if (typeof shell.props.shellVersion === 'string') {
shell.shellVersionArr = (
shell.props.shellVersion.match(/(\d+)\.(\d+)\.(\d+)/) || []
)
.slice(1, 4)
.map(Number);
}
events.emit('received-props', shell.props);
transport.onmessage = function (message) {
const msg = JSON.parse(message.data);
if (msg.id === 0) {
const obj = msg.data[QtObjId];
obj.signals.forEach(function (sig) {
send({
type: QtMsgTypes.connectToSignal,
object: QtObjId,
signal: sig[1],
});
});
obj.properties.slice(1).forEach(function (prop) {
shell.props[prop[1]] = prop[3];
});
if (typeof shell.props.shellVersion === 'string') {
shell.shellVersionArr = (
shell.props.shellVersion.match(/(\d+)\.(\d+)\.(\d+)/) || []
)
.slice(1, 4)
.map(Number);
}
events.emit('received-props', shell.props);
const onEvent = obj.methods.filter(function (x) {
return x[0] === 'onEvent';
})[0];
obj.signals.forEach(function (sig) {
send({
type: QtMsgTypes.connectToSignal,
object: QtObjId,
signal: sig[1],
});
});
const onEvent = obj.methods.filter(function (x) {
return x[0] === 'onEvent';
})[0];
shell.send = function (ev, args) {
send({
type: QtMsgTypes.invokeMethod,
object: QtObjId,
method: onEvent[1],
args: [ev, args || {}],
});
};
shell.send('app-ready', {}); // signal that we're ready to take events
}
if (msg.object === QtObjId && msg.type === QtMsgTypes.signal)
events.emit(msg.args[0], msg.args[1]);
events.emit('init');
shell.send = function (ev, args) {
send({
type: QtMsgTypes.invokeMethod,
object: QtObjId,
method: onEvent[1],
args: [ev, args || {}],
});
};
send({ type: QtMsgTypes.init });
}) .catch((error) => {
events.emit('init-error', error);
});
shell.send('app-ready', {}); // signal that we're ready to take events
}
if (msg.object === QtObjId && msg.type === QtMsgTypes.signal)
events.emit(msg.args[0], msg.args[1]);
};
send({ type: QtMsgTypes.init });
this.on = function(name, listener) {
events.on(name, listener);