Merge branch 'development' into feat/discord-rich-presence

This commit is contained in:
Timothy Z. 2026-04-27 10:09:03 +02:00 committed by GitHub
commit e3be5a4108
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 1373 additions and 532 deletions

View file

@ -14,7 +14,7 @@ jobs:
# Auto assign PR to author
- name: Auto Assign PR to Author
if: github.event.pull_request.head.repo.fork == false && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@ -34,7 +34,7 @@ jobs:
if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View file

@ -22,7 +22,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10
run_install: false

View file

@ -11,7 +11,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
@ -24,7 +24,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.11.3
uses: svenstaro/upload-release-action@2.11.5
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: stremio-web.zip

Binary file not shown.

View file

@ -1,7 +1,7 @@
{
"name": "stremio",
"displayName": "Stremio",
"version": "5.0.0-beta.29",
"version": "5.0.0-beta.35",
"author": "Smart Code OOD",
"private": true,
"license": "gpl-2.0",
@ -17,9 +17,9 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.52.0",
"@stremio/stremio-icons": "5.8.0",
"@stremio/stremio-video": "0.0.70",
"@stremio/stremio-core-web": "0.56.4",
"@stremio/stremio-icons": "5.10.0",
"@stremio/stremio-video": "0.0.77",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",
"buffer": "6.0.3",
@ -41,7 +41,7 @@
"react-i18next": "^15.1.3",
"react-is": "18.3.1",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#7c0c337f32163aa13158bb90cd6133da43feafef",
"stremio-translations": "github:Stremio/stremio-translations#fcad3f8077db865bd08b0f93d785f4090f19db40",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},

View file

@ -18,14 +18,14 @@ importers:
specifier: 5.2.0
version: 5.2.0
'@stremio/stremio-core-web':
specifier: 0.52.0
version: 0.52.0
specifier: 0.56.4
version: 0.56.4
'@stremio/stremio-icons':
specifier: 5.8.0
version: 5.8.0
specifier: 5.10.0
version: 5.10.0
'@stremio/stremio-video':
specifier: 0.0.70
version: 0.0.70
specifier: 0.0.77
version: 0.0.77
a-color-picker:
specifier: 1.2.1
version: 1.2.1
@ -55,7 +55,7 @@ importers:
version: 24.2.3(typescript@5.9.2)
langs:
specifier: github:Stremio/nodejs-langs
version: https://codeload.github.com/Stremio/nodejs-langs/tar.gz/24daad4e78c324fcc88d6673df3cd75348b2efdf
version: https://codeload.github.com/Stremio/nodejs-langs/tar.gz/e5a3af7d2dd556592cbfe7cf879c1064ed0081a1
lodash.debounce:
specifier: 4.0.8
version: 4.0.8
@ -90,8 +90,8 @@ importers:
specifier: github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6
version: https://codeload.github.com/Stremio/spatial-navigation/tar.gz/64871b1422466f5f45d24ebc8bbd315b2ebab6a6
stremio-translations:
specifier: github:Stremio/stremio-translations#7c0c337f32163aa13158bb90cd6133da43feafef
version: https://codeload.github.com/Stremio/stremio-translations/tar.gz/7c0c337f32163aa13158bb90cd6133da43feafef
specifier: github:Stremio/stremio-translations#fcad3f8077db865bd08b0f93d785f4090f19db40
version: https://codeload.github.com/Stremio/stremio-translations/tar.gz/fcad3f8077db865bd08b0f93d785f4090f19db40
url:
specifier: 0.11.4
version: 0.11.4
@ -1120,14 +1120,14 @@ packages:
'@stremio/stremio-colors@5.2.0':
resolution: {integrity: sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg==}
'@stremio/stremio-core-web@0.52.0':
resolution: {integrity: sha512-zT0P8JspGZ1oI9/11f3RIt7XG9b/1fOZE+xSnP+oAyhRmzzkqrnPUJkHdJdgoVD9XELDFAS2awNfl5/eRdh5kA==}
'@stremio/stremio-core-web@0.56.4':
resolution: {integrity: sha512-tFAMYgKrJ1bkvHRMpxDykM/844sDjgRPFk6FLhjQiwh01OHIyEgDqGo/NgwFM+CuMR4mW676SDvwNHkK0Xqg3w==}
'@stremio/stremio-icons@5.8.0':
resolution: {integrity: sha512-IVUvQbIWfA4YEHCTed7v/sdQJCJ+OOCf84LTWpkE2W6GLQ+15WHcMEJrVkE1X3ekYJnGg3GjT0KLO6tKSU0P4w==}
'@stremio/stremio-icons@5.10.0':
resolution: {integrity: sha512-Zw/vGC3D2yeQfk8xv/tfMJTDvbCPOI91tBg4XpR2+EgbZSX8Xvm7Vz457PIhFPhTAwdOPHp0VX0M3gzjbt0zOg==}
'@stremio/stremio-video@0.0.70':
resolution: {integrity: sha512-a0flQYAUdrZNMm7mmts2vpZOqN1nus7Hs9Mjl4mrN5rtduD0ojUyhD5J4lPcCpZ7WB0YdEUOGLXR19qHpgoKmg==}
'@stremio/stremio-video@0.0.77':
resolution: {integrity: sha512-bnKBS5a9R3+M0zx95YpDUiPs1gXcKCsybgdxfZmpWuQaN0RE9bTBAUlIfBSrcEjVhufMOvg+cfXScT+0fBzTTw==}
'@stylistic/eslint-plugin-jsx@4.4.1':
resolution: {integrity: sha512-83SInq4u7z71vWwGG+6ViOtlOmZ6tSrDkMPhrvdBBTGMLA0gs22WSdhQ4vZP3oJ5Xg4ythvqeUiFSedvVxzhyA==}
@ -3051,8 +3051,8 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
langs@https://codeload.github.com/Stremio/nodejs-langs/tar.gz/24daad4e78c324fcc88d6673df3cd75348b2efdf:
resolution: {tarball: https://codeload.github.com/Stremio/nodejs-langs/tar.gz/24daad4e78c324fcc88d6673df3cd75348b2efdf}
langs@https://codeload.github.com/Stremio/nodejs-langs/tar.gz/e5a3af7d2dd556592cbfe7cf879c1064ed0081a1:
resolution: {tarball: https://codeload.github.com/Stremio/nodejs-langs/tar.gz/e5a3af7d2dd556592cbfe7cf879c1064ed0081a1}
version: 2.0.0
launch-editor@2.11.1:
@ -4133,9 +4133,9 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/7c0c337f32163aa13158bb90cd6133da43feafef:
resolution: {tarball: https://codeload.github.com/Stremio/stremio-translations/tar.gz/7c0c337f32163aa13158bb90cd6133da43feafef}
version: 1.45.0
stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/fcad3f8077db865bd08b0f93d785f4090f19db40:
resolution: {tarball: https://codeload.github.com/Stremio/stremio-translations/tar.gz/fcad3f8077db865bd08b0f93d785f4090f19db40}
version: 1.51.0
string-length@4.0.2:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
@ -5870,13 +5870,13 @@ snapshots:
'@stremio/stremio-colors@5.2.0': {}
'@stremio/stremio-core-web@0.52.0':
'@stremio/stremio-core-web@0.56.4':
dependencies:
'@babel/runtime': 7.24.1
'@stremio/stremio-icons@5.8.0': {}
'@stremio/stremio-icons@5.10.0': {}
'@stremio/stremio-video@0.0.70':
'@stremio/stremio-video@0.0.77':
dependencies:
buffer: 6.0.3
color: 4.2.3
@ -8295,7 +8295,7 @@ snapshots:
kleur@3.0.3: {}
langs@https://codeload.github.com/Stremio/nodejs-langs/tar.gz/24daad4e78c324fcc88d6673df3cd75348b2efdf: {}
langs@https://codeload.github.com/Stremio/nodejs-langs/tar.gz/e5a3af7d2dd556592cbfe7cf879c1064ed0081a1: {}
launch-editor@2.11.1:
dependencies:
@ -9378,7 +9378,7 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/7c0c337f32163aa13158bb90cd6133da43feafef: {}
stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/fcad3f8077db865bd08b0f93d785f4090f19db40: {}
string-length@4.0.2:
dependencies:

View file

@ -44,7 +44,7 @@ const ServicesToaster = () => {
}
case 'MagnetParsed': {
toast.show({
type: 'success',
type: 'info',
title: 'Magnet link parsed',
timeout: 4000
});

View file

@ -8,6 +8,12 @@
src: url('/assets/fonts/PlusJakartaSans.ttf') format('truetype');
}
@font-face {
font-family: 'TwemojiFlags';
src: url('/assets/fonts/TwemojiFlags.woff2') format('woff2');
unicode-range: U+1F1E6-1F1FF;
}
:global {
@import (once, less) '~stremio/common/animations.less';
@import (once, less) '~stremio-router/styles.css';
@ -23,8 +29,8 @@
// HTML sizes
@html-width: ~"calc(max(var(--small-viewport-width), var(--dynamic-viewport-width)))";
@html-height: ~"calc(max(var(--small-viewport-height), var(--dynamic-viewport-height)))";
@html-standalone-width: ~"calc(max(100%, var(--small-viewport-width)))";
@html-standalone-height: ~"calc(max(100%, var(--small-viewport-height)))";
@html-standalone-width: ~"calc(max(100%, var(--large-viewport-width)))";
@html-standalone-height: ~"calc(max(100%, var(--large-viewport-height)))";
// Safe area insets
@safe-area-inset-top: env(safe-area-inset-top, 0rem);
@ -151,11 +157,12 @@ svg {
html {
width: @html-width;
height: @html-height;
font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'sans-serif';
font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'TwemojiFlags', 'sans-serif';
overflow: auto;
overscroll-behavior: none;
user-select: none;
touch-action: manipulation;
background-color: var(--primary-background-color);
-webkit-tap-highlight-color: transparent;
@media (display-mode: standalone) {

View file

@ -2,6 +2,8 @@
const CHROMECAST_RECEIVER_APP_ID = '1634F54B';
const DEFAULT_STREAMING_SERVER_URL = 'http://127.0.0.1:11470/';
const DEFAULT_SUBTITLES_LANGUAGE = 'eng';
const LOCAL_SUBTITLES_LANGUAGE = 'local';
const SUBTITLES_SIZES = [75, 100, 125, 150, 175, 200, 250];
const SUBTITLES_FONTS = ['PlusJakartaSans', 'Arial', 'Halvetica', 'Times New Roman', 'Verdana', 'Courier', 'Lucida Console', 'sans-serif', 'serif', 'monospace'];
const SEEK_TIME_DURATIONS = [3000, 5000, 10000, 15000, 20000, 30000];
@ -97,6 +99,16 @@ const EXTERNAL_PLAYERS = [
value: 'moonplayer',
platforms: ['visionos'],
},
{
label: 'Infuse',
value: 'infuse',
platforms: ['ios', 'visionos', 'macos'],
},
{
label: 'Vidhub',
value: 'vidhub',
platforms: ['ios'],
},
{
label: 'M3U Playlist',
value: 'm3u',
@ -111,6 +123,8 @@ const PROTOCOL = 'stremio:';
module.exports = {
CHROMECAST_RECEIVER_APP_ID,
DEFAULT_STREAMING_SERVER_URL,
DEFAULT_SUBTITLES_LANGUAGE,
LOCAL_SUBTITLES_LANGUAGE,
SUBTITLES_SIZES,
SUBTITLES_FONTS,
SEEK_TIME_DURATIONS,

View file

@ -18,7 +18,9 @@ const PlatformProvider = ({ children }: Props) => {
const openExternal = (url: string) => {
try {
const { hostname } = new URL(url);
const isWhitelisted = WHITELISTED_HOSTS.some((host: string) => hostname.endsWith(host));
const isWhitelisted = WHITELISTED_HOSTS.some((host: string) =>
hostname === host || hostname.endsWith('.' + host)
);
const finalUrl = !isWhitelisted ? `https://www.stremio.com/warning#${encodeURIComponent(url)}` : url;
window.open(finalUrl, '_blank');

View file

@ -11,13 +11,21 @@ const APPLE_MOBILE_DEVICES = [
const { userAgent, platform, maxTouchPoints } = globalThis.navigator;
// this detects ipad properly in safari
// while bowser does not
const isIOS = APPLE_MOBILE_DEVICES.includes(platform) || (userAgent.includes('Mac') && 'ontouchend' in document);
// Vision Pro uniquely supports the WebXR Device API (navigator.xr),
// while iPads and iPhones do not — this is the most reliable discriminator.
// Both Vision Pro and iPads (iPadOS 13+) report 'Macintosh' in the UA
// and have maxTouchPoints > 1, so we cannot rely on those alone.
const isMacLikeWithTouch = userAgent.includes('Macintosh') && maxTouchPoints > 1;
const isVisionOS = isMacLikeWithTouch && 'xr' in globalThis.navigator;
// Edge case: iPad is included in this function
// Keep in mind maxTouchPoints for Vision Pro might change in the future
const isVisionOS = userAgent.includes('Macintosh') && maxTouchPoints === 5;
// Detect iOS/iPadOS devices:
// - Older iPads expose 'iPad' in navigator.platform
// - iPadOS 13+ exposes 'MacIntel' but has touch support ('ontouchend' in document)
// - Exclude Vision OS devices which also pass the touch check
const isIOS = !isVisionOS && (
APPLE_MOBILE_DEVICES.includes(platform) ||
(userAgent.includes('Mac') && 'ontouchend' in document)
);
const bowser = Bowser.getParser(userAgent);
const os = bowser.getOSName().toLowerCase();

View file

@ -1,15 +1,16 @@
import { DependencyList, useCallback, useEffect } from 'react';
import { ShortcutListener, ShortcutName, useShortcuts } from './Shortcuts';
const onShortcut = (name: ShortcutName, listener: ShortcutListener, deps: DependencyList) => {
const onShortcut = (name: ShortcutName, listener: ShortcutListener, deps: DependencyList, enabled = true) => {
const shortcuts = useShortcuts();
const listenerCallback = useCallback(listener, deps);
useEffect(() => {
if (!enabled) return;
shortcuts.on(name, listenerCallback);
return () => shortcuts.off(name, listenerCallback);
}, [listenerCallback]);
}, [listenerCallback, enabled]);
};
export default onShortcut;

View file

@ -74,6 +74,21 @@
"label": "SETTINGS_SHORTCUT_SUBTITLES_DELAY",
"combos": [["G"], ["H"]]
},
{
"name": "speedDown",
"label": "SETTINGS_SHORTCUT_DECREASE_PLAYBACK_SPEED",
"combos": [["["]]
},
{
"name": "speedUp",
"label": "SETTINGS_SHORTCUT_INCREASE_PLAYBACK_SPEED",
"combos": [["]"]]
},
{
"name": "toggleSubtitles",
"label": "SETTINGS_SHORTCUT_TOGGLE_SUBTITLES",
"combos": [["C"]]
},
{
"name": "subtitlesMenu",
"label": "SETTINGS_SHORTCUT_MENU_SUBTITLES",
@ -98,6 +113,11 @@
"name": "statisticsMenu",
"label": "SETTINGS_SHORTCUT_MENU_STATISTICS",
"combos": [["D"]]
},
{
"name": "playNext",
"label": "SETTINGS_SHORTCUT_PLAY_NEXT",
"combos": [["Shift", "N"]]
}
]
}

View file

@ -6,6 +6,7 @@ const React = require('react');
const ToastContext = React.createContext({
show: () => { },
remove: () => { },
clear: () => { }
});

View file

@ -42,7 +42,7 @@ const ToastProvider = ({ className, children }) => {
},
show: (item) => {
if (filters.some((filter) => filter(item))) {
return;
return null;
}
const timeout = typeof item.timeout === 'number' && !isNaN(item.timeout) ?
@ -64,6 +64,11 @@ const ToastProvider = ({ className, children }) => {
onClose: itemOnClose
}
});
return id;
},
remove: (id) => {
clearTimeout(id);
dispatch({ type: 'remove', id });
},
clear: () => {
dispatch({ type: 'clear' });

View file

@ -88,7 +88,7 @@
.fade-active {
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.32, 0, 0.67, 0);
transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0);
}
.fade-exit {

View file

@ -1,25 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const comparatorWithPriorities = (priorities) => {
return (a, b) => {
if (isNaN(priorities[a]) && isNaN(priorities[b])) {
return a.localeCompare(b);
} else if (isNaN(priorities[a])) {
if (priorities[b] === Number.NEGATIVE_INFINITY) {
return -1;
} else {
return 1;
}
} else if (isNaN(priorities[b])) {
if (priorities[a] === Number.NEGATIVE_INFINITY) {
return 1;
} else {
return -1;
}
} else {
return priorities[b] - priorities[a];
}
};
};
module.exports = comparatorWithPriorities;

View file

@ -5,7 +5,6 @@ const { PlatformProvider, usePlatform } = require('./Platform');
const { ToastProvider, useToast } = require('./Toast');
const { TooltipProvider, Tooltip } = require('./Tooltips');
const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts');
const comparatorWithPriorities = require('./comparatorWithPriorities');
const CONSTANTS = require('./CONSTANTS');
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
const getVisibleChildrenRange = require('./getVisibleChildrenRange');
@ -27,6 +26,7 @@ const { default: useSettings } = require('./useSettings');
const { default: useShell } = require('./useShell');
const useStreamingServer = require('./useStreamingServer');
const { default: useTimeout } = require('./useTimeout');
const { default: usePlayUrl } = require('./usePlayUrl');
const useTorrent = require('./useTorrent');
const useTranslate = require('./useTranslate');
const { default: useOrientation } = require('./useOrientation');
@ -44,7 +44,6 @@ module.exports = {
useToast,
TooltipProvider,
Tooltip,
comparatorWithPriorities,
CONSTANTS,
withCoreSuspender,
useCoreSuspender,
@ -67,6 +66,7 @@ module.exports = {
useShell,
useStreamingServer,
useTimeout,
usePlayUrl,
useTorrent,
useTranslate,
useOrientation,

View file

@ -99,6 +99,10 @@
"name": "한국어",
"codes": ["ko-KR", "kor"]
},
{
"name": "Lietuvių",
"codes": ["lt-LT", "ltu"]
},
{
"name": "македонски јазик",
"codes": ["mk-MK", "mkd"]
@ -107,6 +111,10 @@
"name": "ဗမာစာ",
"codes": ["my-BM", "mya"]
},
{
"name": "नेपाली",
"codes": ["ne-NP", "nep"]
},
{
"name": "Norsk bokmål",
"codes": ["nb-NO", "nob"]

View file

@ -6,11 +6,16 @@ const all = langs.all().map((lang) => ({
label: lang.local,
alpha2: lang['1'],
alpha3: [lang['2'], lang['2B'], lang['2T'], lang['3']],
locale: lang['locale'],
ietf: lang['ietf'],
}));
const find = (code: string) => {
return all.find(({ alpha2, alpha3, locale }) => [alpha2, ...alpha3, locale].includes(code));
return all.find(({ alpha2, alpha3, ietf }) => [alpha2, ...alpha3, ietf].includes(code));
};
const toCode = (code: string) => {
const language = find(code);
return language?.[2] ?? code;
};
const label = (code: string) => {
@ -21,5 +26,6 @@ const label = (code: string) => {
export {
all,
find,
toCode,
label,
};

65
src/common/usePlayUrl.ts Normal file
View file

@ -0,0 +1,65 @@
import { useCallback } from 'react';
import magnet from 'magnet-uri';
import { useServices } from 'stremio/services';
import useToast from 'stremio/common/Toast/useToast';
import useTorrent from 'stremio/common/useTorrent';
import useStreamingServer from 'stremio/common/useStreamingServer';
const HTTP_REGEX = /^https?:\/\/.+/i;
const usePlayUrl = () => {
const { core } = useServices();
const toast = useToast();
const { createTorrentFromMagnet } = useTorrent();
const streamingServer = useStreamingServer();
const handlePlayUrl = useCallback(async (text: string): Promise<boolean> => {
if (!text || !text.trim()) return false;
const trimmed = text.trim();
if (HTTP_REGEX.test(trimmed)) {
toast.show({
type: 'success',
title: 'Loading HTTP stream…',
timeout: 3000
});
try {
const encoded = await core.transport.encodeStream({ url: trimmed });
if (typeof encoded === 'string') {
window.location.hash = `#/player/${encodeURIComponent(encoded)}`;
return true;
}
} catch (e) {
console.error('Failed to encode stream:', e);
}
toast.show({
type: 'error',
title: 'Failed to load HTTP stream.',
timeout: 5000
});
return false;
}
const parsed = magnet.decode(trimmed);
if (parsed && typeof parsed.infoHash === 'string') {
const serverReady = streamingServer.settings !== null
&& streamingServer.settings.type === 'Ready';
if (!serverReady) {
toast.show({
type: 'error',
title: 'Streaming server is not available. Cannot play magnet links.',
timeout: 5000
});
return false;
}
createTorrentFromMagnet(trimmed);
return true;
}
return false;
}, [streamingServer.settings, createTorrentFromMagnet]);
return { handlePlayUrl };
};
export default usePlayUrl;

View file

@ -6,14 +6,22 @@ const { useServices } = require('stremio/services');
const useToast = require('stremio/common/Toast/useToast');
const useStreamingServer = require('stremio/common/useStreamingServer');
const CREATE_TORRENT_TIMEOUT = 20000;
const useTorrent = () => {
const { core } = useServices();
const streamingServer = useStreamingServer();
const toast = useToast();
const createTorrentTimeout = React.useRef(null);
const parsingToastId = React.useRef(null);
const createTorrentFromMagnet = React.useCallback((text) => {
const parsed = magnet.decode(text);
if (parsed && typeof parsed.infoHash === 'string') {
parsingToastId.current = toast.show({
type: 'success',
title: 'Loading magnet link…',
timeout: CREATE_TORRENT_TIMEOUT
});
core.transport.dispatch({
action: 'StreamingServer',
args: {
@ -23,12 +31,13 @@ const useTorrent = () => {
});
clearTimeout(createTorrentTimeout.current);
createTorrentTimeout.current = setTimeout(() => {
toast.remove(parsingToastId.current);
toast.show({
type: 'error',
title: 'It\'s taking a long time to get metadata from the torrent.',
timeout: 10000
title: 'Failed to parse magnet link.',
timeout: 8000
});
}, 10000);
}, CREATE_TORRENT_TIMEOUT);
}
}, []);
React.useEffect(() => {
@ -36,6 +45,7 @@ const useTorrent = () => {
const [, { type }] = streamingServer.torrent;
if (type === 'Ready') {
clearTimeout(createTorrentTimeout.current);
toast.remove(parsingToastId.current);
}
}
}, [streamingServer.torrent]);

View file

@ -8,7 +8,7 @@
@width-mobile: 3rem;
.ratings-container {
.group-container {
display: flex;
flex-direction: row;
align-items: center;
@ -46,7 +46,7 @@
}
@media @phone-landscape {
.ratings-container {
.group-container {
height: @height-mobile;
.icon-container {

View file

@ -0,0 +1,45 @@
// Copyright (C) 2017-2025 Smart code 203358507
import classNames from 'classnames';
import React from 'react';
import Icon from '@stremio/stremio-icons/react';
import { Tooltip } from 'stremio/common/Tooltips';
import styles from './ActionsGroup.less';
type Item = {
icon: string;
label?: string;
filled?: string;
disabled?: boolean;
className?: string;
onClick?: () => void;
};
type Props = {
items: Item[];
className?: string;
};
const ActionsGroup = ({ items, className }: Props) => {
return (
<div className={classNames(styles['group-container'], className)}>
{
items.map((item, index) => (
<div
key={index}
className={classNames(styles['icon-container'], item.className, { [styles['disabled']]: item.disabled })}
onClick={item.onClick}
>
{
item.label &&
<Tooltip label={item.label} position={'top'} />
}
<Icon name={item.icon} className={styles['icon']} />
</div>
))
}
</div>
);
};
export default ActionsGroup;

View file

@ -0,0 +1,6 @@
// Copyright (C) 2017-2025 Smart code 203358507
import ActionsGroup from './ActionsGroup';
export default ActionsGroup;

View file

@ -10,6 +10,7 @@ type Props = {
style?: object,
href?: string,
target?: string
download?: string,
title?: string,
disabled?: boolean,
tabIndex?: number,

View file

@ -1,22 +1,26 @@
import React, { memo, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import Transition from '../Transition';
import styles from './ContextMenu.less';
const PADDING = 8;
type Coordinates = [number, number];
type Size = [number, number];
type Lock = 'top' | 'right' | 'bottom' | 'left';
type Props = {
children: React.ReactNode,
on: RefObject<HTMLElement>[],
autoClose: boolean,
lock?: Lock,
};
const ContextMenu = ({ children, on, autoClose }: Props) => {
const ContextMenu = ({ children, on, autoClose, lock }: Props) => {
const [active, setActive] = useState(false);
const [position, setPosition] = useState<Coordinates>([0, 0]);
const [containerSize, setContainerSize] = useState<Size>([0, 0]);
const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null);
const ref = useCallback((element: HTMLDivElement) => {
element && setContainerSize([element.offsetWidth, element.offsetHeight]);
@ -25,7 +29,32 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
const style = useMemo(() => {
const [viewportWidth, viewportHeight] = [window.innerWidth, window.innerHeight];
const [containerWidth, containerHeight] = containerSize;
const [x, y] = position;
let x: number;
let y: number;
if (lock && triggerRect) {
switch (lock) {
case 'top':
x = triggerRect.left;
y = triggerRect.top - containerHeight;
break;
case 'bottom':
x = triggerRect.left;
y = triggerRect.bottom;
break;
case 'left':
x = triggerRect.left - containerWidth;
y = triggerRect.top;
break;
case 'right':
x = triggerRect.right;
y = triggerRect.top;
break;
}
} else {
[x, y] = position;
}
const left = Math.max(
PADDING,
@ -44,10 +73,9 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
);
return { top, left };
}, [position, containerSize]);
}, [position, containerSize, lock, triggerRect]);
const close = () => {
setPosition([0, 0]);
setActive(false);
};
@ -55,12 +83,17 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
event.stopPropagation();
};
const onContextMenu = (event: MouseEvent) => {
const onContextMenu = useCallback((event: MouseEvent) => {
event.preventDefault();
setPosition([event.clientX, event.clientY]);
if (lock) {
const target = event.currentTarget as HTMLElement;
setTriggerRect(target.getBoundingClientRect());
} else {
setPosition([event.clientX, event.clientY]);
}
setActive(true);
};
}, [lock]);
const handleKeyDown = useCallback((event: KeyboardEvent) => event.key === 'Escape' && close(), []);
@ -76,25 +109,27 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
on.forEach((ref) => ref.current && ref.current.removeEventListener('contextmenu', onContextMenu));
document.removeEventListener('keydown', handleKeyDown);
};
}, [on]);
}, [on, onContextMenu, handleKeyDown]);
return active && createPortal((
<div
className={styles['context-menu-container']}
onMouseDown={close}
onTouchStart={close}
>
return createPortal((
<Transition when={active} name={'fade'}>
<div
ref={ref}
className={styles['context-menu']}
style={style}
onMouseDown={stopPropagation}
onTouchStart={stopPropagation}
onClick={onClick}
className={styles['context-menu-container']}
onMouseDown={close}
onTouchStart={close}
>
{children}
<div
ref={ref}
className={styles['context-menu']}
style={style}
onMouseDown={stopPropagation}
onTouchStart={stopPropagation}
onClick={onClick}
>
{children}
</div>
</div>
</div>
</Transition>
), document.body);
};

View file

@ -5,24 +5,11 @@ const PropTypes = require('prop-types');
const { useServices } = require('stremio/services');
const LibItem = require('stremio/components/LibItem');
const ContinueWatchingItem = ({ _id, notifications, deepLinks, ...props }) => {
const ContinueWatchingItem = ({ _id, notifications, ...props }) => {
const { core } = useServices();
const onClick = React.useCallback(() => {
if (deepLinks?.metaDetailsVideos ?? deepLinks?.metaDetailsStreams) {
window.location = deepLinks?.metaDetailsVideos ?? deepLinks?.metaDetailsStreams;
}
}, [deepLinks]);
const onPlayClick = React.useCallback((event) => {
event.stopPropagation();
if (deepLinks?.player ?? deepLinks?.metaDetailsStreams ?? deepLinks?.metaDetailsVideos) {
window.location = deepLinks?.player ?? deepLinks?.metaDetailsStreams ?? deepLinks?.metaDetailsVideos;
}
}, [deepLinks]);
const onDismissClick = React.useCallback((event) => {
event.stopPropagation();
event.preventDefault();
if (typeof _id === 'string') {
core.transport.dispatch({
action: 'Ctx',
@ -47,8 +34,6 @@ const ContinueWatchingItem = ({ _id, notifications, deepLinks, ...props }) => {
_id={_id}
posterChangeCursor={true}
notifications={notifications}
onClick={onClick}
onPlayClick={onPlayClick}
onDismissClick={onDismissClick}
/>
);

View file

@ -7,7 +7,7 @@ type Props = {
src: string,
alt: string,
fallbackSrc: string,
renderFallback: () => void,
renderFallback: () => React.ReactNode,
onError: (event: React.SyntheticEvent<HTMLImageElement>) => void,
};

View file

@ -29,7 +29,7 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
case 'details':
return props.deepLinks && (typeof props.deepLinks.metaDetailsVideos === 'string' || typeof props.deepLinks.metaDetailsStreams === 'string');
case 'watched':
return props.deepLinks && (typeof props.deepLinks.metaDetailsVideos === 'string' || typeof props.deepLinks.metaDetailsStreams === 'string');
return typeof watched !== 'undefined' && props.deepLinks && (typeof props.deepLinks.metaDetailsVideos === 'string' || typeof props.deepLinks.metaDetailsStreams === 'string');
case 'dismiss':
return typeof _id === 'string' && props.progress !== null && !isNaN(props.progress) && props.progress > 0;
case 'remove':
@ -119,6 +119,16 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
}
}, [_id, props.deepLinks, props.optionOnSelect]);
const onPlayClick = React.useMemo(() => {
if (props.deepLinks && typeof props.deepLinks.player === 'string') {
return (event) => {
event.preventDefault();
window.location = props.deepLinks.player;
};
}
return null;
}, [props.deepLinks]);
return (
<MetaItem
{...props}
@ -126,6 +136,7 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
newVideos={newVideos}
options={options}
optionOnSelect={optionOnSelect}
onPlayClick={onPlayClick}
/>
);
};

View file

@ -18,14 +18,14 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, poste
const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
const href = React.useMemo(() => {
return deepLinks ?
typeof deepLinks.player === 'string' ?
deepLinks.player
typeof deepLinks.metaDetailsStreams === 'string' ?
deepLinks.metaDetailsStreams
:
typeof deepLinks.metaDetailsStreams === 'string' ?
deepLinks.metaDetailsStreams
typeof deepLinks.metaDetailsVideos === 'string' ?
deepLinks.metaDetailsVideos
:
typeof deepLinks.metaDetailsVideos === 'string' ?
deepLinks.metaDetailsVideos
typeof deepLinks.player === 'string' ?
deepLinks.player
:
null
:

View file

@ -14,7 +14,7 @@ const MetaLinks = ({ className, label, links }) => {
{
typeof label === 'string' && label.length > 0 ?
<div className={styles['label-container']}>
{ stringWithPrefix(label.toUpperCase(), 'LINKS') }
{ stringWithPrefix(label.toUpperCase(), 'LINKS_') }
</div>
:
null

View file

@ -8,6 +8,7 @@ const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { default: Button } = require('stremio/components/Button');
const { default: Image } = require('stremio/components/Image');
const { default: ActionsGroup } = require('stremio/components/ActionsGroup');
const ModalDialog = require('stremio/components/ModalDialog');
const SharePrompt = require('stremio/components/SharePrompt');
const CONSTANTS = require('stremio/common/CONSTANTS');
@ -25,7 +26,7 @@ const ALLOWED_LINK_REDIRECTS = [
routesRegexp.metadetails.regexp
];
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, ratingInfo }, ref) => {
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, watched, toggleWatched, ratingInfo }, ref) => {
const { t } = useTranslation();
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
const linksGroups = React.useMemo(() => {
@ -98,6 +99,18 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
const renderLogoFallback = React.useCallback(() => (
<div className={styles['logo-placeholder']}>{name}</div>
), [name]);
const metaItemActions = React.useMemo(() => [
{
icon: inLibrary ? 'remove-from-library' : 'add-to-library',
label: inLibrary ? t('REMOVE_FROM_LIB') : t('ADD_TO_LIB'),
onClick: typeof toggleInLibrary === 'function' ? toggleInLibrary : null,
},
{
icon: watched ? 'eye-off' : 'eye',
label: watched ? t('CTX_MARK_UNWATCHED') : t('CTX_MARK_WATCHED'),
onClick: typeof toggleWatched === 'function' ? toggleWatched : undefined,
},
], [inLibrary, watched, toggleInLibrary, toggleWatched]);
return (
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })} ref={ref}>
{
@ -195,19 +208,6 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
}
</div>
<div className={styles['action-buttons-container']}>
{
typeof toggleInLibrary === 'function' ?
<ActionButton
className={styles['action-button']}
icon={inLibrary ? 'remove-from-library' : 'add-to-library'}
label={inLibrary ? t('REMOVE_FROM_LIB') : t('ADD_TO_LIB')}
tooltip={compact}
tabIndex={compact ? -1 : 0}
onClick={toggleInLibrary}
/>
:
null
}
{
typeof trailerHref === 'string' ?
<ActionButton
@ -221,6 +221,11 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
:
null
}
{
typeof toggleInLibrary === 'function' && typeof toggleWatched === 'function'
? <ActionsGroup items={metaItemActions} className={styles['group-container']} />
: null
}
{
typeof showHref === 'string' && compact ?
<ActionButton
@ -237,7 +242,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
!compact && ratingInfo !== null ?
<Ratings
ratingInfo={ratingInfo}
className={styles['ratings']}
className={styles['group-container']}
/>
:
null
@ -298,6 +303,8 @@ MetaPreview.propTypes = {
trailerStreams: PropTypes.array,
inLibrary: PropTypes.bool,
toggleInLibrary: PropTypes.func,
watched: PropTypes.bool,
toggleWatched: PropTypes.func,
ratingInfo: PropTypes.object,
};

View file

@ -1,10 +1,9 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import useRating from './useRating';
import styles from './Ratings.less';
import Icon from '@stremio/stremio-icons/react';
import classNames from 'classnames';
import { ActionsGroup } from 'stremio/components';
type Props = {
metaId?: string;
@ -13,18 +12,27 @@ type Props = {
};
const Ratings = ({ ratingInfo, className }: Props) => {
const { t } = useTranslation();
const { onLiked, onLoved, liked, loved } = useRating(ratingInfo);
const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]);
const items = useMemo(() => [
{
icon: liked ? 'thumbs-up' : 'thumbs-up-outline',
label: liked ? t('RATING_UNLIKE') : t('RATING_LIKE'),
disabled,
onClick: onLiked,
},
{
icon: loved ? 'heart' : 'heart-outline',
label: loved ? t('RATING_UNLOVE') : t('RATING_LOVE'),
disabled,
onClick: onLoved,
},
], [liked, loved, disabled]);
return (
<div className={classNames(styles['ratings-container'], className)}>
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLiked}>
<Icon name={liked ? 'thumbs-up' : 'thumbs-up-outline'} className={styles['icon']} />
</div>
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLoved}>
<Icon name={loved ? 'heart' : 'heart-outline'} className={styles['icon']} />
</div>
</div>
<ActionsGroup items={items} className={className} />
);
};

View file

@ -32,7 +32,7 @@
.action-buttons-container {
justify-content: space-between;
.action-button:not(:last-child) {
.action-button:not(:last-child), .group-container:not(:last-child) {
margin-right: 0;
}
}
@ -207,11 +207,20 @@
}
}
}
}
.group-container {
margin-bottom: 1rem;
.ratings {
margin-bottom: 1rem;
margin-right: 1rem;
&:global(.wide) {
width: auto;
padding: 0 2rem;
border-radius: 4rem;
}
&:not(:last-child) {
margin-right: 1rem;
}
}
}
}
@ -233,17 +242,13 @@
padding-top: 1.5rem;
gap: 0.5rem;
.action-button {
.action-button, .group-container {
padding: 0 1.5rem !important;
margin-right: 0rem !important;
height: 3rem;
border-radius: 2rem;
}
}
.ratings {
margin-right: 0;
}
}
}
@ -272,6 +277,10 @@
&::-webkit-scrollbar {
display: none;
}
.action-button {
padding: 0 1rem !important;
}
}
}

View file

@ -12,7 +12,7 @@ const NavMenu = require('./NavMenu');
const styles = require('./styles');
const { t } = require('i18next');
const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, ...props }) => {
const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, hdrInfo, ...props }) => {
const backButtonOnClick = React.useCallback(() => {
window.history.back();
}, []);
@ -53,6 +53,14 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
null
}
<div className={styles['buttons-container']}>
{
hdrInfo && (hdrInfo.gamma === 'pq' || hdrInfo.gamma === 'hlg') ?
<div className={styles['hdr-indicator']} title={hdrInfo.gamma === 'pq' ? 'HDR10' : 'HLG'}>
<Icon className={styles['icon']} name={'hdr'} />
</div>
:
null
}
{
!isIOSPWA && fullscreenButton ?
<Button className={styles['button-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} tabIndex={-1} onClick={fullscreen ? exitFullscreen : requestFullscreen}>
@ -82,7 +90,10 @@ HorizontalNavBar.propTypes = {
backButton: PropTypes.bool,
searchBar: PropTypes.bool,
fullscreenButton: PropTypes.bool,
navMenu: PropTypes.bool
navMenu: PropTypes.bool,
hdrInfo: PropTypes.shape({
gamma: PropTypes.string,
}),
};
module.exports = HorizontalNavBar;

View file

@ -10,7 +10,8 @@ const { Button } = require('stremio/components');
const { default: useFullscreen } = require('stremio/common/useFullscreen');
const useProfile = require('stremio/common/useProfile');
const usePWA = require('stremio/common/usePWA');
const useTorrent = require('stremio/common/useTorrent');
const { default: usePlayUrl } = require('stremio/common/usePlayUrl');
const useToast = require('stremio/common/Toast/useToast');
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
const useStreamingServer = require('stremio/common/useStreamingServer');
const styles = require('./styles');
@ -20,7 +21,8 @@ const NavMenuContent = ({ onClick }) => {
const { core } = useServices();
const profile = useProfile();
const streamingServer = useStreamingServer();
const { createTorrentFromMagnet } = useTorrent();
const { handlePlayUrl } = usePlayUrl();
const toast = useToast();
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
const [isIOSPWA, isAndroidPWA] = usePWA();
const streamingServerWarningDismissed = React.useMemo(() => {
@ -40,11 +42,18 @@ const NavMenuContent = ({ onClick }) => {
const onPlayMagnetLinkClick = React.useCallback(async () => {
try {
const clipboardText = await navigator.clipboard.readText();
createTorrentFromMagnet(clipboardText);
const handled = await handlePlayUrl(clipboardText);
if (!handled) {
toast.show({
type: 'error',
title: 'Clipboard does not contain a valid URL or magnet link.',
timeout: 5000
});
}
} catch(e) {
console.error(e);
}
}, []);
}, [handlePlayUrl]);
return (
<div className={classnames(styles['nav-menu-container'], 'animation-fade-in', { [styles['with-warning']]: !streamingServerWarningDismissed } )} onClick={onClick}>
<div className={styles['user-info-container']}>

View file

@ -9,7 +9,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
const { useRouteFocused } = require('stremio-router');
const Button = require('stremio/components/Button').default;
const TextInput = require('stremio/components/TextInput').default;
const useTorrent = require('stremio/common/useTorrent');
const { default: usePlayUrl } = require('stremio/common/usePlayUrl');
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
const useSearchHistory = require('./useSearchHistory');
const useLocalSearch = require('./useLocalSearch');
@ -21,7 +21,7 @@ const SearchBar = React.memo(({ className, query, active }) => {
const routeFocused = useRouteFocused();
const searchHistory = useSearchHistory();
const localSearch = useLocalSearch();
const { createTorrentFromMagnet } = useTorrent();
const { handlePlayUrl } = usePlayUrl();
const [historyOpen, openHistory, closeHistory, ] = useBinaryState(query === null ? true : false);
const [currentQuery, setCurrentQuery] = React.useState(query || '');
@ -52,12 +52,14 @@ const SearchBar = React.memo(({ className, query, active }) => {
const value = searchInputRef.current.value;
setCurrentQuery(value);
openHistory();
try {
createTorrentFromMagnet(value);
} catch (error) {
console.error('Failed to create torrent from magnet:', error);
}, []);
const queryInputOnPaste = React.useCallback((event) => {
const pasted = event.clipboardData.getData('text');
if (pasted) {
handlePlayUrl(pasted);
}
}, [createTorrentFromMagnet]);
}, [handlePlayUrl]);
const queryInputOnSubmit = React.useCallback((event) => {
event.preventDefault();
@ -108,6 +110,7 @@ const SearchBar = React.memo(({ className, query, active }) => {
defaultValue={query}
tabIndex={-1}
onChange={queryInputOnChange}
onPaste={queryInputOnPaste}
onSubmit={queryInputOnSubmit}
onClick={openHistory}
/>

View file

@ -57,10 +57,30 @@
.buttons-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
overflow: visible;
}
.hdr-indicator {
flex: none;
display: flex;
align-items: center;
justify-content: center;
height: 3.5rem;
padding: 0 0.5rem;
opacity: 0.6;
user-select: none;
.icon {
flex: none;
width: 3rem;
height: 1.5rem;
color: var(--primary-foreground-color);
opacity: 0.6;
}
}
.button-container {
flex: none;
display: flex;

View file

@ -5,9 +5,10 @@ type Props = {
children: JSX.Element,
when: boolean,
name: string,
duration?: number,
};
const Transition = ({ children, when, name }: Props) => {
const Transition = ({ children, when, name, duration }: Props) => {
const [element, setElement] = useState<HTMLElement | null>(null);
const [mounted, setMounted] = useState(false);
@ -29,6 +30,10 @@ const Transition = ({ children, when, name }: Props) => {
);
}, [name, state, active, children]);
const style = useMemo(() => {
if (duration) return { transitionDuration: `${duration}ms` };
}, [duration]);
const onTransitionEnd = useCallback(() => {
state === 'exit' && setMounted(false);
}, [state]);
@ -39,9 +44,10 @@ const Transition = ({ children, when, name }: Props) => {
}, [when]);
useEffect(() => {
requestAnimationFrame(() => {
const animationFrame = requestAnimationFrame(() => {
setActive(!!element);
});
return () => cancelAnimationFrame(animationFrame);
}, [element]);
useEffect(() => {
@ -53,6 +59,7 @@ const Transition = ({ children, when, name }: Props) => {
mounted && cloneElement(children, {
ref: callbackRef,
className,
style,
})
);
};

View file

@ -30,6 +30,7 @@ import TextInput from './TextInput';
import Toggle from './Toggle';
import Transition from './Transition';
import Video from './Video';
import ActionsGroup from './ActionsGroup';
export {
AddonDetailsModal,
@ -65,4 +66,5 @@ export {
Toggle,
Transition,
Video,
ActionsGroup
};

View file

@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Stremio">
<link rel="icon" type="image/x-icon" href="<%= htmlWebpackPlugin.options.faviconsPath %>/favicon.ico">
<link rel="manifest" href="manifest.json" />

View file

@ -18,7 +18,7 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
() => {
const selectableCatalog = remoteAddons.selectable.catalogs
.find(({ id }) => id === remoteAddons.selected.request.path.id);
return selectableCatalog ? t.stringWithPrefix(selectableCatalog.name, 'ADDON_') : remoteAddons.selected.request.path.id;
return selectableCatalog ? t.stringWithPrefix(selectableCatalog.name.toUpperCase(), 'ADDON_') : remoteAddons.selected.request.path.id;
}
: null,
onSelect: (value) => {

View file

@ -23,6 +23,11 @@ const Discover = ({ urlParams, queryParams }) => {
const [addonModalOpen, openAddonModal, closeAddonModal] = useBinaryState(false);
const [selectedMetaItemIndex, setSelectedMetaItemIndex] = React.useState(0);
const selectedMetaItem = React.useMemo(() => {
return discover.catalog?.content.type === 'Ready' &&
discover.catalog.content.content[selectedMetaItemIndex] || null;
}, [discover.catalog, selectedMetaItemIndex]);
const metasContainerRef = React.useRef();
const metaPreviewRef = React.useRef();
@ -40,14 +45,6 @@ const Discover = ({ urlParams, queryParams }) => {
}
}
}, [hasNextPage, loadNextPage]);
const selectedMetaItem = React.useMemo(() => {
return discover.catalog !== null &&
discover.catalog.content.type === 'Ready' &&
discover.catalog.content.content[selectedMetaItemIndex] ?
discover.catalog.content.content[selectedMetaItemIndex]
:
null;
}, [discover.catalog, selectedMetaItemIndex]);
const addToLibrary = React.useCallback(() => {
if (selectedMetaItem === null) {
return;
@ -74,6 +71,22 @@ const Discover = ({ urlParams, queryParams }) => {
}
});
}, [selectedMetaItem]);
const toggleWatched = React.useCallback(() => {
if (selectedMetaItem === null) {
return;
}
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'MetaItemMarkAsWatched',
args: {
meta_item: selectedMetaItem,
is_watched: !selectedMetaItem.watched,
}
}
});
}, [selectedMetaItem]);
const metaItemsOnFocusCapture = React.useCallback((event) => {
if (event.target.dataset.index !== null && !isNaN(event.target.dataset.index)) {
setSelectedMetaItemIndex(parseInt(event.target.dataset.index, 10));
@ -193,6 +206,8 @@ const Discover = ({ urlParams, queryParams }) => {
trailerStreams={selectedMetaItem.trailerStreams}
inLibrary={selectedMetaItem.inLibrary}
toggleInLibrary={selectedMetaItem.inLibrary ? removeFromLibrary : addToLibrary}
watched={selectedMetaItem.watched}
toggleWatched={toggleWatched}
metaId={selectedMetaItem.id}
like={selectedMetaItem.like}
/>

View file

@ -54,13 +54,13 @@ const mapSelectableInputs = (discover, t) => {
value
})
})),
value: JSON.stringify({
value: selectedExtra ? JSON.stringify({
href: selectedExtra.deepLinks.discover,
value: selectedExtra.value,
}),
}) : undefined,
title: options.some(({ selected, value }) => selected && value === null) ?
() => t.string(name.toUpperCase())
: t.string(selectedExtra.value),
: selectedExtra ? t.string(selectedExtra.value) : () => t.string(name.toUpperCase()),
onSelect: (value) => {
const { href } = JSON.parse(value);
window.location = href;

View file

@ -183,7 +183,7 @@ const Intro = ({ queryParams }) => {
return;
}
if (!state.privacyPolicyAccepted) {
dispatch({ type: 'error', error: 'You must accept the Privacy Policy' });
dispatch({ type: 'error', error: t('MUST_ACCEPT_PRIVACY_POLICY') });
return;
}
openLoaderModal();

View file

@ -19,7 +19,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
emailRef.current.value.length > 0 && emailRef.current.validity.valid ?
platform.openExternal('https://www.strem.io/reset-password/' + emailRef.current.value, '_blank')
:
setError('Invalid email');
setError(t('INVALID_EMAIL'));
}, []);
const passwordResetModalButtons = React.useMemo(() => {
return [
@ -31,7 +31,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
}
},
{
label: t('SEND'),
label: t('BUTTON_SEND'),
props: {
onClick: goToPasswordReset
}
@ -52,7 +52,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
ref={emailRef}
className={styles['credentials-text-input']}
type={'email'}
placeholder={'Email'}
placeholder={t('EMAIL')}
defaultValue={typeof email === 'string' ? email : ''}
onChange={emailOnChange}
onSubmit={goToPasswordReset}

View file

@ -16,6 +16,9 @@ const EpisodePicker = ({ className, onSubmit }: Props) => {
const { initialSeason, initialEpisode } = useMemo(() => {
const splitPath = window.location.hash.split('/');
if (splitPath[splitPath.length - 1] === '') {
splitPath.pop();
}
const videoId = decodeURIComponent(splitPath[splitPath.length - 1]);
const [, pathSeason, pathEpisode] = videoId ? videoId.split(':') : [];
return {

View file

@ -64,6 +64,19 @@ const MetaDetails = ({ urlParams, queryParams }) => {
}
});
}, [metaDetails]);
const toggleWatched = React.useCallback(() => {
if (metaDetails.metaItem === null || metaDetails.metaItem.content.type !== 'Ready') {
return;
}
core.transport.dispatch({
action: 'MetaDetails',
args: {
action: 'MarkAsWatched',
args: !metaDetails.metaItem.content.content.watched
}
});
}, [metaDetails]);
const toggleNotifications = React.useCallback(() => {
if (metaDetails.libraryItem) {
core.transport.dispatch({
@ -81,7 +94,11 @@ const MetaDetails = ({ urlParams, queryParams }) => {
const handleEpisodeSearch = React.useCallback((season, episode) => {
const searchVideoHash = encodeURIComponent(`${urlParams.id}:${season}:${episode}`);
const url = window.location.hash;
const searchVideoPath = url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash);
const searchVideoPath = (urlParams.videoId === undefined || urlParams.videoId === null || urlParams.videoId === '') ?
url + (!url.endsWith('/') ? '/' : '') + searchVideoHash
: url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash);
window.location = searchVideoPath;
}, [urlParams, window.location]);
@ -168,6 +185,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
trailerStreams={metaDetails.metaItem.content.content.trailerStreams}
inLibrary={metaDetails.metaItem.content.content.inLibrary}
toggleInLibrary={metaDetails.metaItem.content.content.inLibrary ? removeFromLibrary : addToLibrary}
watched={metaDetails.metaItem.content.content.watched}
toggleWatched={toggleWatched}
metaId={metaDetails.metaItem.content.content.id}
ratingInfo={metaDetails.ratingInfo}
/>

View file

@ -93,6 +93,10 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
return deepLinks?.externalPlayer?.download;
}, [deepLinks]);
const magnetLink = React.useMemo(() => {
return deepLinks?.externalPlayer?.magnet;
}, [deepLinks]);
const markVideoAsWatched = React.useCallback(() => {
if (typeof videoId === 'string') {
core.transport.dispatch({
@ -106,6 +110,10 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
}, [videoId, videoReleased]);
const onClick = React.useCallback((event) => {
if (event.nativeEvent.togglePopupPrevented) {
return;
}
if (profile.settings.playerType !== null) {
markVideoAsWatched();
toast.show({
@ -120,6 +128,28 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
}
}, [props.onClick, profile.settings, markVideoAsWatched]);
const copyMagnetLink = React.useCallback((event) => {
event.preventDefault();
closeMenu();
if (magnetLink) {
navigator.clipboard.writeText(magnetLink)
.then(() => {
toast.show({
type: 'success',
title: t('PLAYER_COPY_MAGNET_LINK_SUCCESS'),
timeout: 4000
});
})
.catch(() => {
toast.show({
type: 'error',
title: t('PLAYER_COPY_MAGNET_LINK_ERROR'),
timeout: 4000,
});
});
}
}, [magnetLink]);
const copyDownloadLink = React.useCallback((event) => {
event.preventDefault();
closeMenu();
@ -221,6 +251,13 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
<div className={styles['context-menu-option-label']}>{t('CTX_COPY_STREAM_LINK')}</div>
</Button>
}
{
magnetLink &&
<Button className={styles['context-menu-option-container']} title={t('CTX_COPY_MAGNET_LINK')} onClick={copyMagnetLink}>
<Icon className={styles['menu-icon']} name={'magnet-link'} />
<div className={styles['context-menu-option-label']}>{t('CTX_COPY_MAGNET_LINK')}</div>
</Button>
}
{
downloadLink &&
<Button className={styles['context-menu-option-container']} title={t('CTX_DOWNLOAD_VIDEO')} onClick={copyDownloadLink}>
@ -267,6 +304,7 @@ Stream.propTypes = {
player: PropTypes.string,
externalPlayer: PropTypes.shape({
download: PropTypes.string,
magnet: PropTypes.string,
streaming: PropTypes.string,
playlist: PropTypes.string,
fileName: PropTypes.string,

View file

@ -60,6 +60,7 @@
width: 7rem;
font-size: 1.1rem;
text-align: left;
white-space: pre-wrap;
color: var(--primary-foreground-color);
}

View file

@ -108,7 +108,9 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
<Icon className={styles['icon']} name={'chevron-back'} />
</Button>
<div className={styles['episode-title']}>
{`S${video?.season}E${video?.episode} ${(video?.title)}`}
{typeof video.season === 'number' && typeof video.episode === 'number'
? `S${video.season}E${video.episode}${video.title ? ` ${video.title}` : ''}`
: (video.title ?? '')}
</div>
</React.Fragment>
:
@ -168,17 +170,6 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
</div>
:
<React.Fragment>
{
countLoadingAddons > 0 ?
<div className={styles['addons-loading-container']}>
<div className={styles['addons-loading']}>
{countLoadingAddons} {t('MOBILE_ADDONS_LOADING')}
</div>
<span className={styles['addons-loading-bar']}></span>
</div>
:
null
}
<div className={styles['streams-container']} ref={streamsContainerRef}>
{filteredStreams.map((stream, index) => (
<Stream
@ -204,6 +195,17 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
null
}
</div>
{
countLoadingAddons > 0 ?
<div className={styles['addons-loading-container']}>
<div className={styles['addons-loading']}>
{countLoadingAddons} {t('MOBILE_ADDONS_LOADING')}
</div>
<span className={styles['addons-loading-bar']}></span>
</div>
:
null
}
</React.Fragment>
}
</div>

View file

@ -50,11 +50,12 @@
display: flex;
z-index: 1;
overflow: visible;
margin: 2em 1em 0 1em;
margin: 2em;
gap: 1em;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: var(--border-radius) var(--border-radius) 0 0;
.addons-loading {
color: var(--primary-foreground-color);
@ -198,5 +199,13 @@
background-color: transparent;
}
}
.addons-loading-container {
position: sticky;
bottom: 0;
margin: 0;
padding: 1em;
background-color: var(--primary-background-color);
}
}
}

View file

@ -13,14 +13,14 @@ const SeasonsBarPlaceholder = ({ className }) => {
<div className={classnames(className, styles['seasons-bar-placeholder-container'])}>
<div className={styles['prev-season-button']}>
<Icon className={styles['icon']} name={'chevron-back'} />
<div className={styles['label']}>{t('SEASON_PREV')}</div>
<div className={styles['label']}>{t('PREV_SEASON')}</div>
</div>
<div className={styles['seasons-popup-label-container']}>
<div className={styles['seasons-popup-label']}>{t('SEASON_NUMBER', { season: 1 })}</div>
<Icon className={styles['seasons-popup-icon']} name={'caret-down'} />
</div>
<div className={styles['next-season-button']}>
<div className={styles['label']}>{t('SEASON_NEXT')}</div>
<div className={styles['label']}>{t('NEXT_SEASON')}</div>
<Icon className={styles['icon']} name={'chevron-forward'} />
</div>
</div>

View file

@ -48,7 +48,7 @@ const useMetaDetails = (urlParams) => {
id: urlParams.id,
extra: []
},
streamPath: typeof urlParams.videoId === 'string' ?
streamPath: typeof urlParams.videoId === 'string' && urlParams.videoId !== '' ?
{
resource: 'stream',
type: urlParams.type,

View file

@ -12,7 +12,11 @@ const useSeason = (urlParams, queryParams) => {
const setSeason = React.useCallback((season) => {
const nextQueryParams = new URLSearchParams(queryParams);
nextQueryParams.set('season', season);
window.location.replace(`#${urlParams.path}?${nextQueryParams}`);
const path = urlParams.path.endsWith('/') ?
urlParams.path.slice(0, -1):
urlParams.path;
window.location.replace(`#${path}?${nextQueryParams}`);
}, [urlParams, queryParams]);
return [season, setSeason];
};

View file

@ -1,4 +1,4 @@
import React, { MouseEvent, useCallback } from 'react';
import React, { forwardRef, memo, MouseEvent, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { languages } from 'stremio/common';
@ -12,7 +12,7 @@ type Props = {
onAudioTrackSelected: (id: string) => void,
};
const AudioMenu = ({ className, selectedAudioTrackId, audioTracks, onAudioTrackSelected }: Props) => {
const AudioMenu = memo(forwardRef<HTMLDivElement, Props>(({ className, selectedAudioTrackId, audioTracks, onAudioTrackSelected }: Props, ref) => {
const { t } = useTranslation();
const onAudioTrackClick = useCallback(({ currentTarget }: MouseEvent) => {
@ -26,7 +26,7 @@ const AudioMenu = ({ className, selectedAudioTrackId, audioTracks, onAudioTrackS
};
return (
<div className={classNames(className, styles['audio-menu'])} onMouseDown={onMouseDown}>
<div ref={ref} className={classNames(className, styles['audio-menu'])} onMouseDown={onMouseDown}>
<div className={styles['container']}>
<div className={styles['header']}>
{ t('AUDIO_TRACKS') }
@ -62,6 +62,6 @@ const AudioMenu = ({ className, selectedAudioTrackId, audioTracks, onAudioTrackS
</div>
</div>
);
};
}));
export default AudioMenu;

View file

@ -12,7 +12,7 @@ const styles = require('./styles');
const { useBinaryState, usePlatform } = require('stremio/common');
const { t } = require('i18next');
const ControlBar = ({
const ControlBar = React.forwardRef(({
className,
paused,
time,
@ -39,10 +39,13 @@ const ControlBar = ({
onToggleSpeedMenu,
onToggleSideDrawer,
onToggleOptionsMenu,
videoScale,
videoScaleLabel,
onVideoScaleChanged,
onToggleStatisticsMenu,
onTouchEnd,
...props
}) => {
}, ref) => {
const { chromecast } = useServices();
const platform = usePlatform();
const [chromecastServiceActive, setChromecastServiceActive] = React.useState(() => chromecast.active);
@ -105,7 +108,7 @@ const ControlBar = ({
};
}, []);
return (
<div {...props} onTouchStart={props.onMouseOver} onTouchMove={props.onMouseMove} onTouchEnd={onTouchEnd} className={classnames(className, styles['control-bar-container'])}>
<div ref={ref} {...props} onTouchStart={props.onMouseOver} onTouchMove={props.onMouseMove} onTouchEnd={onTouchEnd} className={classnames(className, styles['control-bar-container'])}>
<SeekBar
className={styles['seek-bar']}
time={time}
@ -176,6 +179,9 @@ const ControlBar = ({
:
null
}
<Button className={classnames(styles['control-bar-button'], { 'disabled': videoScale === null })} title={videoScaleLabel} tabIndex={-1} onClick={onVideoScaleChanged}>
<Icon className={styles['icon']} name={'scale'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
<Icon className={styles['icon']} name={'more-horizontal'} />
</Button>
@ -183,7 +189,7 @@ const ControlBar = ({
</div>
</div>
);
};
});
ControlBar.propTypes = {
className: PropTypes.string,
@ -194,6 +200,9 @@ ControlBar.propTypes = {
volume: PropTypes.number,
muted: PropTypes.bool,
playbackSpeed: PropTypes.number,
videoScale: PropTypes.string,
videoScaleLabel: PropTypes.string,
onVideoScaleChanged: PropTypes.func,
subtitlesTracks: PropTypes.array,
audioTracks: PropTypes.array,
metaItem: PropTypes.object,

View file

@ -7,7 +7,13 @@ import styles from './Indicator.less';
type Property = {
label: string,
format: (value: number) => string,
format: (value: number | string) => string,
};
const VIDEO_SCALE_KEYS: Record<string, string> = {
'contain': 'PLAYER_SCALE_FIT',
'cover': 'PLAYER_SCALE_CROP',
'fill': 'PLAYER_SCALE_STRETCH',
};
const PROPERTIES: Record<string, Property> = {
@ -15,9 +21,13 @@ const PROPERTIES: Record<string, Property> = {
label: 'SUBTITLES_DELAY',
format: (value) => `${(value / 1000).toFixed(2)}s`,
},
'videoScale': {
label: 'VIDEO_SCALE',
format: (value) => t(VIDEO_SCALE_KEYS[String(value)] || String(value)),
},
};
type VideoState = Record<string, number>;
type VideoState = Record<string, number | string>;
type Props = {
className: string,
@ -28,6 +38,7 @@ type Props = {
const Indicator = ({ className, videoState, disabled }: Props) => {
const timeout = useRef<NodeJS.Timeout | null>(null);
const prevVideoState = useRef<VideoState>(videoState);
const initialized = useRef<Set<string>>(new Set());
const [shown, show, hide] = useBinaryState(false);
const [current, setCurrent] = useState<string | null>(null);
@ -49,11 +60,15 @@ const Indicator = ({ className, videoState, disabled }: Props) => {
const next = videoState[property];
if (next && next !== prev) {
setCurrent(property);
show();
if (!initialized.current.has(property)) {
initialized.current.add(property);
} else {
setCurrent(property);
show();
timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(hide, 1000);
timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(hide, 1000);
}
}
}
@ -61,7 +76,7 @@ const Indicator = ({ className, videoState, disabled }: Props) => {
}, [videoState]);
return (
<Transition when={shown && !disabled} name={'fade'}>
<Transition when={shown && !disabled} name={'fade'} duration={300}>
<div className={classNames(className, styles['indicator-container'])}>
<div className={styles['indicator']}>
<div>{label} {value}</div>

View file

@ -9,18 +9,22 @@ const { useServices } = require('stremio/services');
const Option = require('./Option');
const styles = require('./styles');
const OptionsMenu = ({ className, stream, playbackDevices, extraSubtitlesTracks, selectedExtraSubtitlesTrackId }) => {
const OptionsMenu = React.memo(React.forwardRef(({ className, stream, playbackDevices, extraSubtitlesTracks, selectedExtraSubtitlesTrackId }, ref) => {
const { t } = useTranslation();
const { core } = useServices();
const platform = usePlatform();
const toast = useToast();
const [streamingUrl, downloadUrl] = React.useMemo(() => {
const [streamingUrl, downloadUrl, magnetUrl] = React.useMemo(() => {
return stream !== null ?
stream.deepLinks &&
stream.deepLinks.externalPlayer &&
[stream.deepLinks.externalPlayer.streaming, stream.deepLinks.externalPlayer.download]
[
stream.deepLinks.externalPlayer.streaming,
stream.deepLinks.externalPlayer.download,
stream.deepLinks.externalPlayer.magnet,
]
:
[null, null];
[null, null, null];
}, [stream]);
const externalDevices = React.useMemo(() => {
return playbackDevices.filter(({ type }) => type === 'external');
@ -46,18 +50,40 @@ const OptionsMenu = ({ className, stream, playbackDevices, extraSubtitlesTracks,
console.error(e);
toast.show({
type: 'error',
title: t('Error'),
title: t('ERROR'),
message: `${t('PLAYER_COPY_STREAM_ERROR')}: ${streamingUrl || downloadUrl}`,
timeout: 3000
});
});
}
}, [streamingUrl, downloadUrl]);
const onDownloadVideoButtonClick = React.useCallback(() => {
if (downloadUrl || streamingUrl ) {
platform.openExternal(downloadUrl || streamingUrl);
const onCopyMagnetButtonClick = React.useCallback(() => {
if (magnetUrl) {
navigator.clipboard.writeText(magnetUrl)
.then(() => {
toast.show({
type: 'success',
title: 'Copied',
message: t('PLAYER_COPY_MAGNET_LINK_SUCCESS'),
timeout: 3000
});
})
.catch((e) => {
console.error(e);
toast.show({
type: 'error',
title: t('Error'),
message: `${t('PLAYER_COPY_MAGNET_LINK_ERROR')}: ${magnetUrl}`,
timeout: 3000
});
});
}
}, [streamingUrl, downloadUrl]);
}, [magnetUrl]);
const onDownloadVideoButtonClick = React.useCallback(() => {
if (downloadUrl) {
platform.openExternal(downloadUrl);
}
}, [downloadUrl]);
const onDownloadSubtitlesClick = React.useCallback(() => {
subtitlesTrackUrl && platform.openExternal(subtitlesTrackUrl);
@ -82,7 +108,7 @@ const OptionsMenu = ({ className, stream, playbackDevices, extraSubtitlesTracks,
}, []);
return (
<div className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
<div ref={ref} className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
{
streamingUrl || downloadUrl ?
<Option
@ -95,7 +121,18 @@ const OptionsMenu = ({ className, stream, playbackDevices, extraSubtitlesTracks,
null
}
{
streamingUrl || downloadUrl ?
magnetUrl ?
<Option
icon={'magnet-link'}
label={t('CTX_COPY_MAGNET_LINK')}
disabled={stream === null}
onClick={onCopyMagnetButtonClick}
/>
:
null
}
{
downloadUrl ?
<Option
icon={'download'}
label={t('CTX_DOWNLOAD_VIDEO')}
@ -130,7 +167,7 @@ const OptionsMenu = ({ className, stream, playbackDevices, extraSubtitlesTracks,
}
</div>
);
};
}));
OptionsMenu.propTypes = {
className: PropTypes.string,

View file

@ -28,6 +28,7 @@ const useVideo = require('./useVideo');
const styles = require('./styles');
const Video = require('./Video');
const { default: Indicator } = require('./Indicator/Indicator');
const { default: useMediaSession } = require('./useMediaSession');
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
const findTrackById = (tracks, id) => tracks.find((track) => track.id === id);
@ -73,8 +74,8 @@ const Player = ({ urlParams, queryParams }) => {
const [sideDrawerOpen, , closeSideDrawer, toggleSideDrawer] = useBinaryState(false);
const menusOpen = React.useMemo(() => {
return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || sideDrawerOpen;
}, [optionsMenuOpen, subtitlesMenuOpen, audioMenuOpen, speedMenuOpen, statisticsMenuOpen, sideDrawerOpen]);
return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || sideDrawerOpen || nextVideoPopupOpen;
}, [optionsMenuOpen, subtitlesMenuOpen, audioMenuOpen, speedMenuOpen, statisticsMenuOpen, sideDrawerOpen, nextVideoPopupOpen]);
const closeMenus = React.useCallback(() => {
closeOptionsMenu();
@ -86,16 +87,28 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
const overlayHidden = React.useMemo(() => {
return immersed && !casting && video.state.paused !== null && !video.state.paused && !menusOpen && !nextVideoPopupOpen;
}, [immersed, casting, video.state.paused, menusOpen, nextVideoPopupOpen]);
return immersed && !casting && video.state.paused !== null && !video.state.paused && !menusOpen;
}, [immersed, casting, video.state.paused, menusOpen]);
const nextVideoPopupDismissed = React.useRef(false);
const defaultSubtitlesSelected = React.useRef(false);
const subtitlesEnabled = React.useRef(true);
const defaultAudioTrackSelected = React.useRef(false);
const playingOnExternalDevice = React.useRef(false);
const [error, setError] = React.useState(null);
const isNavigating = React.useRef(false);
const VIDEO_SCALES = ['contain', 'cover', 'fill'];
const VIDEO_SCALE_LABELS = { contain: t('PLAYER_SCALE_FIT'), cover: t('PLAYER_SCALE_CROP'), fill: t('PLAYER_SCALE_STRETCH') };
const playbackSpeed = React.useRef(video.state.playbackSpeed || 1);
const pressTimer = React.useRef(null);
const longPress = React.useRef(false);
const controlBarRef = React.useRef(null);
const HOLD_DELAY = 200;
const onImplementationChanged = React.useCallback(() => {
video.setSubtitlesSize(settings.subtitlesSize);
video.setSubtitlesOffset(settings.subtitlesOffset);
@ -189,6 +202,7 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
const onPlayRequested = React.useCallback(() => {
playingOnExternalDevice.current = false;
video.setPaused(false);
setSeeking(false);
}, []);
@ -217,27 +231,33 @@ const Player = ({ urlParams, queryParams }) => {
seek(time, video.state.duration, video.state.manifest?.name);
}, [video.state.duration, video.state.manifest]);
const onPlaybackSpeedChanged = React.useCallback((rate) => {
const onPlaybackSpeedChanged = React.useCallback((rate, skipUpdate) => {
video.setPlaybackSpeed(rate);
if (skipUpdate) return;
playbackSpeed.current = rate;
}, []);
const onSubtitlesTrackSelected = React.useCallback((id) => {
video.setSubtitlesTrack(id);
const onVideoScaleChanged = React.useCallback(() => {
const currentScale = video.state.videoScale || 'contain';
const currentIndex = VIDEO_SCALES.indexOf(currentScale);
const nextScale = VIDEO_SCALES[(currentIndex + 1) % VIDEO_SCALES.length];
video.setVideoScale(nextScale);
}, [video.state.videoScale]);
const onSubtitlesTrackSelected = React.useCallback((track) => {
video.setSubtitlesTrack(track?.id ?? null);
streamStateChanged({
subtitleTrack: {
id,
embedded: true,
},
subtitleTrack: track ? { id: track.id, embedded: true, lang: track.lang } : null,
});
}, [streamStateChanged]);
const onExtraSubtitlesTrackSelected = React.useCallback((id) => {
video.setExtraSubtitlesTrack(id);
const onExtraSubtitlesTrackSelected = React.useCallback((track) => {
video.setExtraSubtitlesTrack(track?.id ?? null);
streamStateChanged({
subtitleTrack: {
id,
embedded: false,
},
subtitleTrack: track ? { id: track.id, embedded: false, lang: track.lang } : null,
});
}, [streamStateChanged]);
@ -296,14 +316,14 @@ const Player = ({ urlParams, queryParams }) => {
}, [player.nextVideo, handleNextVideoNavigation, profile.settings]);
const onVideoClick = React.useCallback(() => {
if (video.state.paused !== null) {
if (video.state.paused !== null && !longPress.current) {
if (video.state.paused) {
onPlayRequestedDebounced();
} else {
onPauseRequestedDebounced();
}
}
}, [video.state.paused]);
}, [video.state.paused, longPress.current]);
const onVideoDoubleClick = React.useCallback(() => {
onPlayRequestedDebounced.cancel();
@ -364,7 +384,7 @@ const Player = ({ urlParams, queryParams }) => {
subtitles: Array.isArray(player.selected.stream.subtitles) ?
player.selected.stream.subtitles.map((subtitles) => ({
...subtitles,
label: subtitles.url
label: subtitles.label || subtitles.url
}))
:
[]
@ -401,7 +421,7 @@ const Player = ({ urlParams, queryParams }) => {
if (video.state.stream !== null) {
const tracks = player.subtitles.map((subtitles) => ({
...subtitles,
label: subtitles.url
label: subtitles.label || subtitles.url
}));
video.addExtraSubtitlesTracks(tracks);
}
@ -412,7 +432,9 @@ const Player = ({ urlParams, queryParams }) => {
}, [video.state.time, video.state.duration, video.state.manifest, seeking]);
React.useEffect(() => {
if (video.state.paused !== null) {
if (playingOnExternalDevice.current && video.state.paused === false) {
onPauseRequested();
} else if (video.state.paused !== null) {
pausedChanged(video.state.paused);
}
}, [video.state.paused]);
@ -450,23 +472,34 @@ const Player = ({ urlParams, queryParams }) => {
}
const savedTrackId = player.streamState?.subtitleTrack?.id;
const subtitlesTrack = savedTrackId ?
findTrackById(video.state.subtitlesTracks, savedTrackId) :
findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
const savedLang = player.streamState?.subtitleTrack?.lang;
const savedIsExternal = savedTrackId && player.streamState?.subtitleTrack?.embedded === false;
const extraSubtitlesTrack = savedTrackId ?
findTrackById(video.state.extraSubtitlesTracks, savedTrackId) :
findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
const subtitlesTrack =
savedTrackId ? findTrackById(video.state.subtitlesTracks, savedTrackId) :
savedLang ? findTrackByLang(video.state.subtitlesTracks, savedLang) :
findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
const extraSubtitlesTrack =
savedTrackId ? findTrackById(video.state.extraSubtitlesTracks, savedTrackId) :
savedLang ? findTrackByLang(video.state.extraSubtitlesTracks, savedLang) :
findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
if (subtitlesTrack && subtitlesTrack.id) {
video.setSubtitlesTrack(subtitlesTrack.id);
if (video.state.selectedSubtitlesTrackId !== subtitlesTrack.id) {
video.setSubtitlesTrack(subtitlesTrack.id);
}
defaultSubtitlesSelected.current = true;
} else if (extraSubtitlesTrack && extraSubtitlesTrack.id) {
video.setExtraSubtitlesTrack(extraSubtitlesTrack.id);
defaultSubtitlesSelected.current = true;
if (video.state.selectedExtraSubtitlesTrackId !== extraSubtitlesTrack.id) {
video.setExtraSubtitlesTrack(extraSubtitlesTrack.id);
}
if (savedIsExternal) {
defaultSubtitlesSelected.current = true;
}
}
}
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, player.streamState]);
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.selectedSubtitlesTrackId, video.state.selectedExtraSubtitlesTrackId, player.streamState]);
// Auto audio track selection
React.useEffect(() => {
@ -507,6 +540,7 @@ const Player = ({ urlParams, queryParams }) => {
defaultSubtitlesSelected.current = false;
defaultAudioTrackSelected.current = false;
nextVideoPopupDismissed.current = false;
playingOnExternalDevice.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);
@ -548,6 +582,7 @@ const Player = ({ urlParams, queryParams }) => {
};
const onCoreEvent = ({ event }) => {
if (event === 'PlayingOnDevice') {
playingOnExternalDevice.current = true;
onPauseRequested();
}
};
@ -589,103 +624,83 @@ const Player = ({ urlParams, queryParams }) => {
};
}, [discord.connected, discord.available, player?.title, player?.metaItem, video.state]);
// Media Session PlaybackState
useMediaSession(video.state, player, onPlayRequested, onPauseRequested, onNextVideoRequested);
React.useEffect(() => {
if (!navigator.mediaSession) return;
const playbackState = !video.state.paused ? 'playing' : 'paused';
navigator.mediaSession.playbackState = playbackState;
return () => navigator.mediaSession.playbackState = 'none';
}, [video.state.paused]);
// Media Session Metadata
React.useEffect(() => {
if (!navigator.mediaSession) return;
const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content : null;
const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
const video = metaItem ? metaItem.videos.find(({ id }) => id === videoId) : null;
const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})` : null;
const videoTitle = video ? `${video.title}${videoInfo}` : null;
const metaTitle = metaItem ? metaItem.name : null;
const imageUrl = metaItem ? metaItem.logo : null;
const title = videoTitle ?? metaTitle;
const artist = videoTitle ? metaTitle : undefined;
const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
if (title) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
artwork,
});
}
}, [player.metaItem, player.selected]);
// Media Session Actions
React.useEffect(() => {
if (!navigator.mediaSession) return;
navigator.mediaSession.setActionHandler('play', onPlayRequested);
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
onShortcut('playPause', () => {
if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
const onMediaKey = (action) => {
switch (action) {
case 'play-pause':
video.state.paused ? onPlayRequested() : onPauseRequested();
break;
case 'next-track':
if (player.nextVideo !== null) {
video.setTime(0);
onNextVideoRequested();
}
break;
case 'previous-track':
if (video.state.time !== null && video.state.time > 5000) {
onSeekRequested(0);
}
break;
}
}
}, [menusOpen, nextVideoPopupOpen, video.state.paused, onPlayRequested, onPauseRequested]);
};
shell.on('media-key', onMediaKey);
return () => shell.off('media-key', onMediaKey);
}, [video.state.paused, video.state.time, player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested, onSeekRequested]);
onShortcut('seekForward', (combo) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
if (video.state.time !== null) {
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time + seekDuration);
}
}, [menusOpen, nextVideoPopupOpen, video.state.time, onSeekRequested]);
}, [video.state.time, onSeekRequested], !menusOpen);
onShortcut('seekBackward', (combo) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
if (video.state.time !== null) {
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time - seekDuration);
}
}, [menusOpen, nextVideoPopupOpen, video.state.time, onSeekRequested]);
}, [video.state.time, onSeekRequested], !menusOpen);
onShortcut('mute', () => {
video.state.muted === true ? onUnmuteRequested() : onMuteRequested();
}, [video.state.muted]);
}, [video.state.muted], !menusOpen);
onShortcut('volumeUp', () => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
if (video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
}, [video.state.volume], !menusOpen);
onShortcut('volumeDown', () => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
if (video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume - 5, 200));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
}, [video.state.volume], !menusOpen);
onShortcut('subtitlesDelay', (combo) => {
combo === 1 ? onIncreaseSubtitlesDelay() : onDecreaseSubtitlesDelay();
}, [onIncreaseSubtitlesDelay, onDecreaseSubtitlesDelay]);
}, [onIncreaseSubtitlesDelay, onDecreaseSubtitlesDelay], !menusOpen);
onShortcut('subtitlesSize', (combo) => {
combo === 1 ? onUpdateSubtitlesSize(-1) : onUpdateSubtitlesSize(1);
}, [onUpdateSubtitlesSize, onUpdateSubtitlesSize]);
combo === 1 ? onUpdateSubtitlesSize(1) : onUpdateSubtitlesSize(-1);
}, [onUpdateSubtitlesSize, onUpdateSubtitlesSize], !menusOpen);
onShortcut('toggleSubtitles', () => {
const savedTrack = player.streamState?.subtitleTrack;
if (subtitlesEnabled.current) {
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
} else if (savedTrack?.id) {
savedTrack.embedded ? video.setSubtitlesTrack(savedTrack.id) : video.setExtraSubtitlesTrack(savedTrack.id);
}
subtitlesEnabled.current = !subtitlesEnabled.current;
}, [player.streamState], !menusOpen);
onShortcut('subtitlesMenu', () => {
closeMenus();
@ -715,25 +730,83 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [video.state.playbackSpeed, toggleSpeedMenu]);
onShortcut('speedUp', () => {
if (video.state.playbackSpeed !== null) {
onPlaybackSpeedChanged(Math.min(video.state.playbackSpeed + 0.25, 2));
}
}, [video.state.playbackSpeed, onPlaybackSpeedChanged], !menusOpen);
onShortcut('speedDown', () => {
if (video.state.playbackSpeed !== null) {
onPlaybackSpeedChanged(Math.max(video.state.playbackSpeed - 0.25, 0.25));
}
}, [video.state.playbackSpeed, onPlaybackSpeedChanged], !menusOpen);
onShortcut('statisticsMenu', () => {
closeMenus();
const stream = player.selected?.stream;
if (streamingServer?.statistics?.type !== 'Err' && typeof stream === 'string' && typeof stream === 'number') {
if (streamingServer?.statistics?.type !== 'Err' && typeof stream?.infoHash === 'string' && typeof stream?.fileIdx === 'number') {
toggleStatisticsMenu();
}
}, [player.selected, streamingServer.statistics, toggleStatisticsMenu]);
onShortcut('playNext', () => {
closeMenus();
if (window.playerNextVideo !== null) {
nextVideo();
const deepLinks = window.playerNextVideo.deepLinks;
handleNextVideoNavigation(deepLinks, false, false);
}
}, []);
onShortcut('exit', () => {
closeMenus();
!settings.escExitFullscreen && window.history.back();
}, [settings.escExitFullscreen]);
React.useLayoutEffect(() => {
const onKeyUp = (event) => {
if (event.code === 'ArrowRight' || event.code === 'ArrowLeft') {
if (menusOpen) {
clearTimeout(pressTimer.current);
pressTimer.current = null;
longPress.current = false;
}
const onKeyDown = (e) => {
if (e.code !== 'Space' || e.repeat) return;
if (menusOpen) return;
longPress.current = false;
pressTimer.current = setTimeout(() => {
longPress.current = true;
onPlaybackSpeedChanged(2, true);
}, HOLD_DELAY);
};
const onKeyUp = (e) => {
if (e.code !== 'Space' && e.code !== 'ArrowRight' && e.code !== 'ArrowLeft') return;
if (e.code === 'ArrowRight' || e.code === 'ArrowLeft') {
setSeeking(false);
return;
}
if (e.code === 'Space') {
clearTimeout(pressTimer.current);
pressTimer.current = null;
if (longPress.current) {
onPlaybackSpeedChanged(playbackSpeed.current);
} else if (!menusOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
}
}
longPress.current = false;
}
};
const onWheel = ({ deltaY }) => {
if (menusOpen || video.state.volume === null) return;
@ -745,15 +818,57 @@ const Player = ({ urlParams, queryParams }) => {
}
}
};
const onMouseDownHold = (e) => {
if (e.button !== 0) return; // left mouse button only
if (menusOpen) return;
if (controlBarRef.current && controlBarRef.current.contains(e.target)) return;
longPress.current = false;
pressTimer.current = setTimeout(() => {
longPress.current = true;
onPlaybackSpeedChanged(2, true);
}, HOLD_DELAY);
};
const onMouseUp = (e) => {
if (e.button !== 0) return;
clearTimeout(pressTimer.current);
if (longPress.current) {
onPlaybackSpeedChanged(playbackSpeed.current);
}
};
const onBlur = () => {
clearTimeout(pressTimer.current);
pressTimer.current = null;
if (longPress.current) {
onPlaybackSpeedChanged(playbackSpeed.current);
longPress.current = false;
}
setSeeking(false);
};
if (routeFocused) {
window.addEventListener('keyup', onKeyUp);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('wheel', onWheel);
window.addEventListener('mousedown', onMouseDownHold);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('blur', onBlur);
}
return () => {
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('wheel', onWheel);
window.removeEventListener('mousedown', onMouseDownHold);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('blur', onBlur);
};
}, [routeFocused, menusOpen, video.state.volume]);
}, [routeFocused, menusOpen, video.state.volume, video.state.paused]);
React.useEffect(() => {
video.events.on('error', onError);
@ -851,6 +966,7 @@ const Player = ({ urlParams, queryParams }) => {
title={player.title !== null ? player.title : ''}
backButton={true}
fullscreenButton={true}
hdrInfo={video.state.hdrInfo}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
/>
@ -864,6 +980,7 @@ const Player = ({ urlParams, queryParams }) => {
null
}
<ControlBar
ref={controlBarRef}
className={classnames(styles['layer'], styles['control-bar-layer'])}
paused={video.state.paused}
time={video.state.time}
@ -889,6 +1006,9 @@ const Player = ({ urlParams, queryParams }) => {
onToggleSubtitlesMenu={toggleSubtitlesMenu}
onToggleAudioMenu={toggleAudioMenu}
onToggleSpeedMenu={toggleSpeedMenu}
videoScale={video.state.videoScale}
videoScaleLabel={VIDEO_SCALE_LABELS[video.state.videoScale || 'contain']}
onVideoScaleChanged={onVideoScaleChanged}
onToggleStatisticsMenu={toggleStatisticsMenu}
onToggleSideDrawer={toggleSideDrawer}
onMouseMove={onBarMouseMove}
@ -912,15 +1032,12 @@ const Player = ({ urlParams, queryParams }) => {
:
null
}
{
statisticsMenuOpen ?
<StatisticsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
{...statistics}
/>
:
null
}
<Transition when={statisticsMenuOpen} name={'fade'}>
<StatisticsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
{...statistics}
/>
</Transition>
<Transition when={sideDrawerOpen} name={'slide-left'}>
<SideDrawer
className={classnames(styles['layer'], styles['side-drawer-layer'])}
@ -930,63 +1047,53 @@ const Player = ({ urlParams, queryParams }) => {
selected={player.selected?.streamRequest?.path.id}
/>
</Transition>
{
subtitlesMenuOpen ?
<SubtitlesMenu
className={classnames(styles['layer'], styles['menu-layer'])}
subtitlesTracks={video.state.subtitlesTracks}
selectedSubtitlesTrackId={video.state.selectedSubtitlesTrackId}
subtitlesOffset={video.state.subtitlesOffset}
subtitlesSize={video.state.subtitlesSize}
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
extraSubtitlesOffset={video.state.extraSubtitlesOffset}
extraSubtitlesDelay={video.state.extraSubtitlesDelay}
extraSubtitlesSize={video.state.extraSubtitlesSize}
onSubtitlesTrackSelected={onSubtitlesTrackSelected}
onExtraSubtitlesTrackSelected={onExtraSubtitlesTrackSelected}
onSubtitlesOffsetChanged={onSubtitlesOffsetChanged}
onSubtitlesSizeChanged={onSubtitlesSizeChanged}
onExtraSubtitlesOffsetChanged={onSubtitlesOffsetChanged}
onExtraSubtitlesDelayChanged={onExtraSubtitlesDelayChanged}
onExtraSubtitlesSizeChanged={onSubtitlesSizeChanged}
/>
:
null
}
{
audioMenuOpen ?
<AudioMenu
className={classnames(styles['layer'], styles['menu-layer'])}
audioTracks={video.state.audioTracks}
selectedAudioTrackId={video.state.selectedAudioTrackId}
onAudioTrackSelected={onAudioTrackSelected}
/>
:
null
}
{
speedMenuOpen ?
<SpeedMenu
className={classnames(styles['layer'], styles['menu-layer'])}
playbackSpeed={video.state.playbackSpeed}
onPlaybackSpeedChanged={onPlaybackSpeedChanged}
/>
:
null
}
{
optionsMenuOpen ?
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected.stream}
playbackDevices={playbackDevices}
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
/>
:
null
}
<Transition when={subtitlesMenuOpen} name={'fade'}>
<SubtitlesMenu
className={classnames(styles['layer'], styles['menu-layer'])}
subtitlesLanguage={settings.subtitlesLanguage}
interfaceLanguage={settings.interfaceLanguage}
subtitlesTracks={video.state.subtitlesTracks}
selectedSubtitlesTrackId={video.state.selectedSubtitlesTrackId}
subtitlesOffset={video.state.subtitlesOffset}
subtitlesSize={video.state.subtitlesSize}
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
extraSubtitlesOffset={video.state.extraSubtitlesOffset}
extraSubtitlesDelay={video.state.extraSubtitlesDelay}
extraSubtitlesSize={video.state.extraSubtitlesSize}
onSubtitlesTrackSelected={onSubtitlesTrackSelected}
onExtraSubtitlesTrackSelected={onExtraSubtitlesTrackSelected}
onSubtitlesOffsetChanged={onSubtitlesOffsetChanged}
onSubtitlesSizeChanged={onSubtitlesSizeChanged}
onExtraSubtitlesOffsetChanged={onSubtitlesOffsetChanged}
onExtraSubtitlesDelayChanged={onExtraSubtitlesDelayChanged}
onExtraSubtitlesSizeChanged={onSubtitlesSizeChanged}
/>
</Transition>
<Transition when={audioMenuOpen} name={'fade'}>
<AudioMenu
className={classnames(styles['layer'], styles['menu-layer'])}
audioTracks={video.state.audioTracks}
selectedAudioTrackId={video.state.selectedAudioTrackId}
onAudioTrackSelected={onAudioTrackSelected}
/>
</Transition>
<Transition when={speedMenuOpen} name={'fade'}>
<SpeedMenu
className={classnames(styles['layer'], styles['menu-layer'])}
playbackSpeed={video.state.playbackSpeed}
onPlaybackSpeedChanged={onPlaybackSpeedChanged}
/>
</Transition>
<Transition when={optionsMenuOpen} name={'fade'}>
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected?.stream}
playbackDevices={playbackDevices}
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
/>
</Transition>
</div>
);
};

View file

@ -3,6 +3,7 @@
@import (reference) '~stremio/common/screen-sizes.less';
:import('~stremio/components/MetaPreview/styles.less') {
description-container: description-container;
action-buttons-container: action-buttons-container;
}
@ -12,6 +13,7 @@
display: flex;
flex-direction: column;
padding: @padding;
padding-top: calc(@padding + var(--safe-area-inset-top));
height: 100dvh;
max-width: 35rem;
overflow-y: auto;
@ -27,7 +29,7 @@
.close-button {
display: none;
position: absolute;
top: 1.3rem;
top: calc(1.3rem + var(--safe-area-inset-top));
right: 1.3rem;
padding: 0.5rem;
background-color: transparent;
@ -57,9 +59,14 @@
.info {
padding: @padding;
overflow-y: auto;
flex: 1;
.side-drawer-meta-preview {
.description-container {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.action-buttons-container {
padding-top: 0;
margin-top: 0;
@ -91,10 +98,6 @@
@media @phone-landscape {
.side-drawer {
max-width: 50dvw;
.info {
flex: 1;
}
}
}

View file

@ -9,7 +9,7 @@ const styles = require('./styles');
const RATES = Array.from(Array(8).keys(), (n) => n * 0.25 + 0.25).reverse();
const SpeedMenu = ({ className, playbackSpeed, onPlaybackSpeedChanged }) => {
const SpeedMenu = React.memo(React.forwardRef(({ className, playbackSpeed, onPlaybackSpeedChanged }, ref) => {
const { t } = useTranslation();
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.speedMenuClosePrevented = true;
@ -20,7 +20,7 @@ const SpeedMenu = ({ className, playbackSpeed, onPlaybackSpeedChanged }) => {
}
}, [onPlaybackSpeedChanged]);
return (
<div className={classnames(className, styles['speed-menu-container'])} onMouseDown={onMouseDown}>
<div ref={ref} className={classnames(className, styles['speed-menu-container'])} onMouseDown={onMouseDown}>
<div className={styles['title']}>
{ t('PLAYBACK_SPEED') }
</div>
@ -39,7 +39,7 @@ const SpeedMenu = ({ className, playbackSpeed, onPlaybackSpeedChanged }) => {
</div>
</div>
);
};
}));
SpeedMenu.propTypes = {
className: PropTypes.string,

View file

@ -15,7 +15,7 @@
.options-container {
flex: 0 1 auto;
max-height: calc(3.2rem * 8);
max-height: calc(3.2rem * 10);
padding: 0 1rem 0.5rem;
.option {

View file

@ -6,10 +6,13 @@ const classNames = require('classnames');
const PropTypes = require('prop-types');
const styles = require('./styles.less');
const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
const StatisticsMenu = React.memo(React.forwardRef(({ className, peers, speed, completed, infoHash }, ref) => {
const { t } = useTranslation();
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.statisticsMenuClosePrevented = true;
}, []);
return (
<div className={classNames(className, styles['statistics-menu-container'])}>
<div ref={ref} className={classNames(className, styles['statistics-menu-container'])} onMouseDown={onMouseDown}>
<div className={styles['title']}>
{t('PLAYER_STATISTICS')}
</div>
@ -49,7 +52,7 @@ const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
</div>
</div>
);
};
}));
StatisticsMenu.propTypes = {
className: PropTypes.string,

View file

@ -0,0 +1,82 @@
// Copyright (C) 2017-2026 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.variant-option {
display: flex;
flex-direction: row;
align-items: center;
height: 4rem;
padding: 0 1.5rem;
margin-bottom: 0.5rem;
border-radius: var(--border-radius);
&:global(.selected), &:hover {
background-color: var(--overlay-color);
}
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
.variant-label {
flex: 1;
font-size: 1.1rem;
line-height: 1.5rem;
color: var(--primary-foreground-color);
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.variant-origin {
font-size: 0.9rem;
color: var(--color-placeholder-text);
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
.icon {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
margin-left: 1rem;
background-color: var(--secondary-accent-color);
}
}
.context-menu-option {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
min-width: 16rem;
padding: 1.25rem 1.5rem;
&:hover, &:focus {
background-color: var(--overlay-color);
}
.menu-icon {
flex: none;
width: 1.4rem;
height: 1.4rem;
color: var(--color-placeholder);
}
.context-menu-option-label {
flex: 1;
min-width: 0;
font-size: 1rem;
font-weight: 400;
color: var(--primary-foreground-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}

View file

@ -0,0 +1,136 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ContextMenu } from 'stremio/components';
import { languages, useToast } from 'stremio/common';
import classNames from 'classnames';
import Icon from '@stremio/stremio-icons/react';
import styles from './SubtitleVariant.less';
type SubtitlesTrack = {
id: string,
addonSubtitleId?: string,
lang: string,
origin: string,
label?: string,
url?: string,
fallbackUrl?: string,
embedded?: boolean,
local?: boolean,
exclusive?: boolean,
};
type Props = {
track: SubtitlesTrack,
selected: boolean,
onSelect: (track: SubtitlesTrack) => void,
};
const hasValidLabel = (label?: string) => label && label.length > 0 && !label.startsWith('http');
const SubtitleVariant = ({ track, selected, onSelect }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const buttonRef = useRef<HTMLElement>(null);
const triggers = useMemo(() => [buttonRef], []);
const downloadUrl = track.fallbackUrl || track.url;
const variantLabel = hasValidLabel(track.label) ? track.label : languages.label(track.lang);
const downloadFileName = hasValidLabel(track.label) ? track.label : `subtitle-${track.lang || 'unknown'}`;
const canCopyUrl = typeof downloadUrl === 'string' && !downloadUrl.startsWith('blob:');
const hoverTitle = hasValidLabel(track.label)
? track.label
: downloadUrl?.split('/').pop()?.split('?')[0] || variantLabel;
const onSelectClick = useCallback(() => {
onSelect(track);
}, [onSelect, track]);
const copyToClipboard = useCallback((value: string, successKey: string, errorKey: string) => {
navigator.clipboard.writeText(value)
.then(() => toast.show({ type: 'success', title: t(successKey), timeout: 4000 }))
.catch(() => toast.show({ type: 'error', title: t(errorKey), timeout: 4000 }));
}, [toast, t]);
const onCopyUrlClick = useCallback(() => {
if (downloadUrl) {
copyToClipboard(downloadUrl, 'PLAYER_COPY_SUBTITLE_URL_SUCCESS', 'PLAYER_COPY_SUBTITLE_URL_ERROR');
}
}, [downloadUrl, copyToClipboard]);
const onCopyIdClick = useCallback(() => {
if (track.addonSubtitleId) {
copyToClipboard(track.addonSubtitleId, 'PLAYER_COPY_SUBTITLE_ID_SUCCESS', 'PLAYER_COPY_SUBTITLE_ID_ERROR');
}
}, [track.addonSubtitleId, copyToClipboard]);
return (
<Button
ref={buttonRef}
title={hoverTitle}
onClick={onSelectClick}
className={classNames(styles['variant-option'], { 'selected': selected })}
>
<div className={styles['info']}>
<div className={styles['variant-label']}>
{variantLabel}
</div>
<div className={styles['variant-origin']}>
{t(track.origin)}
</div>
</div>
{selected ? <div className={styles['icon']} /> : null}
{!track.embedded &&
<ContextMenu on={triggers} autoClose={true} lock={'bottom'}>
{downloadUrl ?
<Button
className={styles['context-menu-option']}
title={t('CTX_DOWNLOAD_SUBTITLE')}
href={downloadUrl}
target={'_blank'}
download={downloadFileName}
>
<Icon className={styles['menu-icon']} name={'download'} />
<div className={styles['context-menu-option-label']}>
{t('CTX_DOWNLOAD_SUBTITLE')}
</div>
</Button>
:
null
}
{canCopyUrl ?
<Button
className={styles['context-menu-option']}
title={t('CTX_COPY_SUBTITLE_URL')}
onClick={onCopyUrlClick}
>
<Icon className={styles['menu-icon']} name={'link'} />
<div className={styles['context-menu-option-label']}>
{t('CTX_COPY_SUBTITLE_URL')}
</div>
</Button>
:
null
}
{track.addonSubtitleId ?
<Button
className={styles['context-menu-option']}
title={t('CTX_COPY_SUBTITLE_ID')}
onClick={onCopyIdClick}
>
<Icon className={styles['menu-icon']} name={'share'} />
<div className={styles['context-menu-option-label']}>
{t('CTX_COPY_SUBTITLE_ID')}
</div>
</Button>
:
null
}
</ContextMenu>
}
</Button>
);
};
export default SubtitleVariant;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2026 Smart code 203358507
import SubtitleVariant from './SubtitleVariant';
export default SubtitleVariant;

View file

@ -3,39 +3,58 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { comparatorWithPriorities, languages } = require('stremio/common');
const { SUBTITLES_SIZES } = require('stremio/common/CONSTANTS');
const { languages } = require('stremio/common');
const { SUBTITLES_SIZES, DEFAULT_SUBTITLES_LANGUAGE, LOCAL_SUBTITLES_LANGUAGE } = require('stremio/common/CONSTANTS');
const { Button } = require('stremio/components');
const styles = require('./styles');
const { t } = require('i18next');
const { default: Stepper } = require('./Stepper');
const { default: SubtitleVariant } = require('./SubtitleVariant');
const ORIGIN_PRIORITIES = {
'LOCAL': 3,
'EMBEDDED': 2,
'EXCLUSIVE': 1,
};
const LANGUAGE_PRIORITIES = {
'local': 2,
'eng': 1,
};
const ORIGIN_PRIORITIES = [
'LOCAL',
'EMBEDDED',
'EXCLUSIVE',
];
const normalizeTracksLang = (tracks) => tracks.map((track) => ({
...track,
lang: languages.toCode(track.lang),
}));
const sortByValues = (items, values) => items.sort((a, b) => {
const left = values.indexOf(a);
const right = values.indexOf(b);
if (left === -1 && right === -1) return 0;
if (left === -1) return 1;
if (right === -1) return -1;
return left - right;
});
const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
const subtitlesTracks = React.useMemo(() => {
return normalizeTracksLang(Array.isArray(props.subtitlesTracks) ? props.subtitlesTracks : []);
}, [props.subtitlesTracks]);
const extraSubtitlesTracks = React.useMemo(() => {
return normalizeTracksLang(Array.isArray(props.extraSubtitlesTracks) ? props.extraSubtitlesTracks : []);
}, [props.extraSubtitlesTracks]);
const allSubtitles = React.useMemo(() => {
return subtitlesTracks.concat(extraSubtitlesTracks);
}, [subtitlesTracks, extraSubtitlesTracks]);
const SubtitlesMenu = React.memo((props) => {
const subtitlesLanguages = React.useMemo(() => {
return (Array.isArray(props.subtitlesTracks) ? props.subtitlesTracks : [])
.concat(Array.isArray(props.extraSubtitlesTracks) ? props.extraSubtitlesTracks : [])
.reduce((subtitlesLanguages, { lang }) => {
if (!subtitlesLanguages.includes(lang)) {
subtitlesLanguages.push(lang);
}
const userLanguage = languages.toCode(props.subtitlesLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE;
const interfaceLanguage = languages.toCode(props.interfaceLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE;
const priorities = [LOCAL_SUBTITLES_LANGUAGE, userLanguage, interfaceLanguage];
const langs = [...new Set(allSubtitles.map(({ lang }) => lang))].sort((a, b) => a.localeCompare(b));
return sortByValues(langs, priorities);
}, [allSubtitles, props.subtitlesLanguage, props.interfaceLanguage]);
return subtitlesLanguages;
}, [])
.sort(comparatorWithPriorities(LANGUAGE_PRIORITIES));
}, [props.subtitlesTracks, props.extraSubtitlesTracks]);
const selectedSubtitlesLanguage = React.useMemo(() => {
return typeof props.selectedSubtitlesTrackId === 'string' ?
(Array.isArray(props.subtitlesTracks) ? props.subtitlesTracks : [])
subtitlesTracks
.reduce((selectedSubtitlesLanguage, { id, lang }) => {
if (id === props.selectedSubtitlesTrackId) {
return lang;
@ -45,7 +64,7 @@ const SubtitlesMenu = React.memo((props) => {
}, null)
:
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
(Array.isArray(props.extraSubtitlesTracks) ? props.extraSubtitlesTracks : [])
extraSubtitlesTracks
.reduce((selectedSubtitlesLanguage, { id, lang }) => {
if (id === props.selectedExtraSubtitlesTrackId) {
return lang;
@ -55,22 +74,18 @@ const SubtitlesMenu = React.memo((props) => {
}, null)
:
null;
}, [props.subtitlesTracks, props.extraSubtitlesTracks, props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId]);
}, [subtitlesTracks, extraSubtitlesTracks, props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId]);
const subtitlesTracksForLanguage = React.useMemo(() => {
return (Array.isArray(props.subtitlesTracks) ? props.subtitlesTracks : [])
.concat(Array.isArray(props.extraSubtitlesTracks) ? props.extraSubtitlesTracks : [])
.filter(({ lang }) => lang === selectedSubtitlesLanguage)
.sort((t1, t2) => comparatorWithPriorities(ORIGIN_PRIORITIES)(t1.origin, t2.origin));
}, [props.subtitlesTracks, props.extraSubtitlesTracks, selectedSubtitlesLanguage]);
const tracks = allSubtitles.filter(({ lang }) => lang === selectedSubtitlesLanguage);
return sortByValues(tracks, ORIGIN_PRIORITIES);
}, [allSubtitles, selectedSubtitlesLanguage]);
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.subtitlesMenuClosePrevented = true;
}, []);
const subtitlesLanguageOnClick = React.useCallback((event) => {
const track = (Array.isArray(props.subtitlesTracks) ? props.subtitlesTracks : [])
.concat(Array.isArray(props.extraSubtitlesTracks) ? props.extraSubtitlesTracks : [])
.filter(({ lang }) => lang === event.currentTarget.dataset.lang)
.sort((t1, t2) => comparatorWithPriorities(ORIGIN_PRIORITIES)(t1.origin, t2.origin))
.shift();
const tracks = allSubtitles.filter(({ lang }) => lang === event.currentTarget.dataset.lang);
const track = sortByValues(tracks, ORIGIN_PRIORITIES).shift();
if (!track) {
if (typeof props.onSubtitlesTrackSelected === 'function') {
props.onSubtitlesTrackSelected(null);
@ -80,22 +95,22 @@ const SubtitlesMenu = React.memo((props) => {
}
} else if (track.embedded) {
if (typeof props.onSubtitlesTrackSelected === 'function') {
props.onSubtitlesTrackSelected(track.id);
props.onSubtitlesTrackSelected(track);
}
} else {
if (typeof props.onExtraSubtitlesTrackSelected === 'function') {
props.onExtraSubtitlesTrackSelected(track.id);
props.onExtraSubtitlesTrackSelected(track);
}
}
}, [props.subtitlesTracks, props.extraSubtitlesTracks, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
const subtitlesTrackOnClick = React.useCallback((event) => {
if (event.currentTarget.dataset.embedded === 'true') {
}, [allSubtitles, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
const subtitlesTrackOnSelect = React.useCallback((track) => {
if (track.embedded) {
if (typeof props.onSubtitlesTrackSelected === 'function') {
props.onSubtitlesTrackSelected(event.currentTarget.dataset.id);
props.onSubtitlesTrackSelected(track);
}
} else {
if (typeof props.onExtraSubtitlesTrackSelected === 'function') {
props.onExtraSubtitlesTrackSelected(event.currentTarget.dataset.id);
props.onExtraSubtitlesTrackSelected(track);
}
}
}, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
@ -139,7 +154,7 @@ const SubtitlesMenu = React.memo((props) => {
}
}, [props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId, props.subtitlesOffset, props.extraSubtitlesOffset, props.onSubtitlesOffsetChanged, props.onExtraSubtitlesOffsetChanged]);
return (
<div className={classnames(props.className, styles['subtitles-menu-container'])} onMouseDown={onMouseDown}>
<div ref={ref} className={classnames(props.className, styles['subtitles-menu-container'])} onMouseDown={onMouseDown}>
<div className={styles['languages-container']}>
<div className={styles['languages-header']}>{ t('PLAYER_SUBTITLES_LANGUAGES') }</div>
<div className={styles['languages-list']}>
@ -175,24 +190,12 @@ const SubtitlesMenu = React.memo((props) => {
subtitlesTracksForLanguage.length > 0 ?
<div className={styles['variants-list']}>
{subtitlesTracksForLanguage.map((track, index) => (
<Button key={index} title={track.label} className={classnames(styles['variant-option'], { 'selected': props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id })} data-id={track.id} data-origin={track.origin} data-embedded={track.embedded} onClick={subtitlesTrackOnClick}>
<div className={styles['info']}>
<div className={styles['variant-label']}>
{
languages.label(!track.label.startsWith('http') ? track.label : track.lang)
}
</div>
<div className={styles['variant-origin']}>
{ t(track.origin) }
</div>
</div>
{
props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id ?
<div className={styles['icon']} />
:
null
}
</Button>
<SubtitleVariant
key={index}
track={track}
selected={props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id}
onSelect={subtitlesTrackOnSelect}
/>
))}
</div>
:
@ -241,12 +244,14 @@ const SubtitlesMenu = React.memo((props) => {
</div>
</div>
);
});
}));
SubtitlesMenu.displayName = 'MainNavBars';
SubtitlesMenu.propTypes = {
className: PropTypes.string,
subtitlesLanguage: PropTypes.string,
interfaceLanguage: PropTypes.string,
subtitlesTracks: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
lang: PropTypes.string.isRequired,
@ -259,7 +264,11 @@ SubtitlesMenu.propTypes = {
id: PropTypes.string.isRequired,
lang: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
label: PropTypes.string.isRequired
label: PropTypes.string,
url: PropTypes.string,
embedded: PropTypes.bool,
local: PropTypes.bool,
exclusive: PropTypes.bool
})),
selectedExtraSubtitlesTrackId: PropTypes.string,
extraSubtitlesOffset: PropTypes.number,

View file

@ -27,7 +27,7 @@
overflow-y: auto;
padding: 0 1rem;
.language-option, .variant-option {
.language-option {
display: flex;
flex-direction: row;
align-items: center;
@ -40,13 +40,10 @@
background-color: var(--overlay-color);
}
.language-label, .variant-label {
.language-label {
flex: 1;
font-size: 1.1rem;
color: var(--primary-foreground-color);
}
.language-label, .variant-label, .variant-origin {
text-wrap: nowrap;
text-overflow: ellipsis;
}
@ -60,26 +57,6 @@
background-color: var(--secondary-accent-color);
}
}
.variant-option {
height: 4rem;
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
.variant-label {
line-height: 1.5rem;
}
.variant-origin {
font-size: 0.9rem;
color: var(--color-placeholder-text);
}
}
}
}
}

View file

@ -65,7 +65,6 @@ html:not(.active-slider-within) {
top: 0;
left: 0;
z-index: -1;
box-shadow: 0 0 8rem 6rem @color-background-dark5;
content: "";
}
@ -102,7 +101,6 @@ html:not(.active-slider-within) {
bottom: 0;
left: 0;
z-index: -1;
box-shadow: 0 0 8rem 8rem @color-background-dark5;
content: "";
}
}

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react';
const useMediaSession = (
videoState: VideoState,
player: Player,
onPlayRequested: () => void,
onPauseRequested: () => void,
onNextVideoRequested: () => void,
) => {
useEffect(() => {
if (!navigator.mediaSession) return;
const playbackState = !videoState.paused ? 'playing' : 'paused';
navigator.mediaSession.playbackState = playbackState;
return () => {
navigator.mediaSession.playbackState = 'none';
};
}, [videoState.paused]);
useEffect(() => {
if (!navigator.mediaSession) return;
const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content as MetaItemPlayer : null;
const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
const video = metaItem?.videos.find(({ id }) => id === videoId);
const videoInfo = video?.season && video?.episode ? ` (${video.season}x${video.episode})` : null;
const videoTitle = video ? `${video.title}${videoInfo}` : null;
const metaTitle = metaItem ? metaItem.name : null;
const imageUrl = metaItem ? metaItem.logo : null;
const title = videoTitle ?? metaTitle;
const artist = (videoTitle && metaTitle) ?? undefined;
const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
if (title) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
artwork,
});
}
}, [player.metaItem, player.selected]);
useEffect(() => {
if (!navigator.mediaSession) return;
navigator.mediaSession.setActionHandler('play', onPlayRequested);
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
};
export default useMediaSession;

View file

@ -22,6 +22,7 @@ const useVideo = () => {
muted: null,
playbackSpeed: null,
videoParams: null,
hdrInfo: null,
audioTracks: [],
selectedAudioTrackId: null,
subtitlesTracks: [],
@ -142,6 +143,10 @@ const useVideo = () => {
setProp('extraSubtitlesOffset', offset);
};
const setVideoScale = (scale) => {
setProp('videoScale', scale);
};
const setSubtitlesTextColor = (color) => {
setProp('subtitlesTextColor', color);
setProp('extraSubtitlesTextColor', color);
@ -238,6 +243,7 @@ const useVideo = () => {
setSubtitlesBackgroundColor,
setSubtitlesOutlineColor,
setExtraSubtitlesTrack,
setVideoScale,
};
};

3
src/routes/Player/videoState.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
type VideoState = {
paused?: boolean;
};

View file

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { Button } from 'stremio/components';
import { SECTIONS } from '../constants';
import { usePlatform } from 'stremio/common';
import styles from './Menu.less';
type Props = {
@ -15,6 +16,7 @@ type Props = {
const Menu = ({ selected, streamingServer, onSelect }: Props) => {
const { t } = useTranslation();
const { shell } = useServices();
const platform = usePlatform();
const settings = useMemo(() => (
streamingServer?.settings?.type === 'Ready' ?
@ -35,9 +37,9 @@ const Menu = ({ selected, streamingServer, onSelect }: Props) => {
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.STREAMING })} title={t('SETTINGS_NAV_STREAMING')} data-section={SECTIONS.STREAMING} onClick={onSelect}>
{ t('SETTINGS_NAV_STREAMING') }
</Button>
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.SHORTCUTS })} title={t('SETTINGS_NAV_SHORTCUTS')} data-section={SECTIONS.SHORTCUTS} onClick={onSelect}>
{ !platform.isMobile && <Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.SHORTCUTS })} title={t('SETTINGS_NAV_SHORTCUTS')} data-section={SECTIONS.SHORTCUTS} onClick={onSelect}>
{ t('SETTINGS_NAV_SHORTCUTS') }
</Button>
</Button> }
<div className={styles['spacing']} />
<div className={styles['version-info-label']} title={process.env.VERSION}>

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CONSTANTS, languageNames, useLanguageSorting, usePlatform } from 'stremio/common';
import { useServices } from 'stremio/services';
import { CONSTANTS, languageNames, usePlatform, useLanguageSorting } from 'stremio/common';
const LANGUAGES_NAMES: Record<string, string> = languageNames;
@ -232,12 +232,12 @@ const usePlayerOptions = (profile: Profile) => {
const nextVideoPopupDurationSelect = useMemo(() => ({
options: CONSTANTS.NEXT_VIDEO_POPUP_DURATIONS.map((duration) => ({
value: `${duration}`,
label: duration === 0 ? 'Disabled' : `${duration / 1000} ${t('SECONDS')}`
label: duration === 0 ? t('SETTINGS_DISABLED') : `${duration / 1000} ${t('SECONDS')}`
})),
value: `${profile.settings.nextVideoNotificationDuration}`,
title: () => {
return profile.settings.nextVideoNotificationDuration === 0 ?
'Disabled'
t('SETTINGS_DISABLED')
:
`${profile.settings.nextVideoNotificationDuration / 1000} ${t('SECONDS')}`;
},

View file

@ -41,13 +41,27 @@ const Settings = () => {
const updateSelectedSectionId = useCallback(() => {
const container = sectionsContainerRef.current;
for (const section of sections) {
const sectionContainer = section.ref.current;
if (sectionContainer && (sectionContainer.offsetTop + container!.offsetTop) < container!.scrollTop + 50) {
setSelectedSectionId(section.id);
}
if (!container) return;
const availableSections = sections.filter((section) => section.ref.current);
if (!availableSections.length) return;
const { scrollTop, clientHeight, scrollHeight, offsetTop } = container;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 10;
if (isAtBottom) {
setSelectedSectionId(availableSections[availableSections.length - 1].id);
return;
}
}, []);
const marker = scrollTop + 50;
const activeSection = availableSections.reduce((current, section) => {
const sectionTop = section.ref.current!.offsetTop + offsetTop;
return sectionTop <= marker ? section : current;
}, availableSections[0]);
setSelectedSectionId(activeSection.id);
}, [sections]);
const onMenuSelect = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const section = sections.find((section) => {
@ -55,11 +69,11 @@ const Settings = () => {
});
const container = sectionsContainerRef.current;
section && container!.scrollTo({
section && container?.scrollTo({
top: section.ref.current!.offsetTop - container!.offsetTop,
behavior: 'smooth'
});
}, []);
}, [sections]);
const onContainerScroll = useCallback(throttle(() => {
updateSelectedSectionId();

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { ChangeEvent, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Icon from '@stremio/stremio-icons/react';
import { Button, TextInput } from 'stremio/components';
import styles from './AddItem.less';
@ -11,6 +12,7 @@ type Props = {
};
const AddItem = ({ onCancel, handleAddUrl }: Props) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const handleValueChange = useCallback(({ target }: ChangeEvent<HTMLInputElement>) => {
@ -28,7 +30,7 @@ const AddItem = ({ onCancel, handleAddUrl }: Props) => {
value={inputValue}
onChange={handleValueChange}
onSubmit={onSubmit}
placeholder={'Enter URL'}
placeholder={t('SETTINGS_SERVER_ADD_URL_PLACEHOLDER')}
/>
<div className={styles['actions']}>
<Button className={styles['add']} onClick={onSubmit}>

View file

@ -52,6 +52,9 @@ function CoreTransport(args) {
this.decodeStream = async function(stream) {
return bridge.call(['decodeStream'], [stream]);
};
this.encodeStream = async function(stream) {
return bridge.call(['encodeStream'], [stream]);
};
}
module.exports = CoreTransport;

View file

@ -17,6 +17,7 @@ interface CoreTransport {
getState: (model: string) => Promise<object>,
dispatch: (action: Action, model?: string) => Promise<void>,
decodeStream: (stream: string) => Promise<Stream>,
encodeStream: (stream: object) => Promise<string>,
analytics: (event: AnalyticsEvent) => Promise<void>,
on: (name: string, listener: () => void) => void,
off: (name: string, listener: () => void) => void,

View file

@ -5,7 +5,6 @@ type LibraryItemPlayer = Pick<LibraryItem, '_id'> & {
type VideoPlayer = Video & {
upcoming: boolean,
watched: boolean,
progress: boolean | null,
scheduled: boolean,
deepLinks: VideoDeepLinks,
};

View file

@ -150,7 +150,7 @@ module.exports = (env, argv) => ({
]
},
{
test: /\.ttf$/,
test: /\.(ttf|woff2)$/,
exclude: /node_modules/,
type: 'asset/resource',
generator: {