mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-05-24 16:42:30 +00:00
Merge branch 'development' into feat/discord-rich-presence
This commit is contained in:
commit
e3be5a4108
82 changed files with 1373 additions and 532 deletions
4
.github/workflows/auto_assign.yml
vendored
4
.github/workflows/auto_assign.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
BIN
assets/fonts/TwemojiFlags.woff2
Normal file
BIN
assets/fonts/TwemojiFlags.woff2
Normal file
Binary file not shown.
10
package.json
10
package.json
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const ServicesToaster = () => {
|
|||
}
|
||||
case 'MagnetParsed': {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
type: 'info',
|
||||
title: 'Magnet link parsed',
|
||||
timeout: 4000
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const React = require('react');
|
|||
|
||||
const ToastContext = React.createContext({
|
||||
show: () => { },
|
||||
remove: () => { },
|
||||
clear: () => { }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
65
src/common/usePlayUrl.ts
Normal 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;
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
45
src/components/ActionsGroup/ActionsGroup.tsx
Normal file
45
src/components/ActionsGroup/ActionsGroup.tsx
Normal 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;
|
||||
6
src/components/ActionsGroup/index.ts
Normal file
6
src/components/ActionsGroup/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import ActionsGroup from './ActionsGroup';
|
||||
|
||||
export default ActionsGroup;
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ type Props = {
|
|||
style?: object,
|
||||
href?: string,
|
||||
target?: string
|
||||
download?: string,
|
||||
title?: string,
|
||||
disabled?: boolean,
|
||||
tabIndex?: number,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ type Props = {
|
|||
src: string,
|
||||
alt: string,
|
||||
fallbackSrc: string,
|
||||
renderFallback: () => void,
|
||||
renderFallback: () => React.ReactNode,
|
||||
onError: (event: React.SyntheticEvent<HTMLImageElement>) => void,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@
|
|||
width: 7rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
5
src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
Normal file
5
src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import SubtitleVariant from './SubtitleVariant';
|
||||
|
||||
export default SubtitleVariant;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
src/routes/Player/useMediaSession.ts
Normal file
57
src/routes/Player/useMediaSession.ts
Normal 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;
|
||||
|
|
@ -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
3
src/routes/Player/videoState.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
type VideoState = {
|
||||
paused?: boolean;
|
||||
};
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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')}`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
1
src/services/Core/types.d.ts
vendored
1
src/services/Core/types.d.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
1
src/types/models/Player.d.ts
vendored
1
src/types/models/Player.d.ts
vendored
|
|
@ -5,7 +5,6 @@ type LibraryItemPlayer = Pick<LibraryItem, '_id'> & {
|
|||
type VideoPlayer = Video & {
|
||||
upcoming: boolean,
|
||||
watched: boolean,
|
||||
progress: boolean | null,
|
||||
scheduled: boolean,
|
||||
deepLinks: VideoDeepLinks,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ module.exports = (env, argv) => ({
|
|||
]
|
||||
},
|
||||
{
|
||||
test: /\.ttf$/,
|
||||
test: /\.(ttf|woff2)$/,
|
||||
exclude: /node_modules/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue