diff --git a/.github/workflows/auto_assign.yml b/.github/workflows/auto_assign.yml index 24a7cd5fe..58d5ac412 100644 --- a/.github/workflows/auto_assign.yml +++ b/.github/workflows/auto_assign.yml @@ -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: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f61e32b6f..83a3aab34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce392e134..40b474af0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/assets/fonts/TwemojiFlags.woff2 b/assets/fonts/TwemojiFlags.woff2 new file mode 100644 index 000000000..b9d6ea84b Binary files /dev/null and b/assets/fonts/TwemojiFlags.woff2 differ diff --git a/package.json b/package.json index 0c9ca54ec..8f29d78df 100644 --- a/package.json +++ b/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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 435a809e8..2256c21eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/App/ServicesToaster.js b/src/App/ServicesToaster.js index a12757169..7ca7e2914 100644 --- a/src/App/ServicesToaster.js +++ b/src/App/ServicesToaster.js @@ -44,7 +44,7 @@ const ServicesToaster = () => { } case 'MagnetParsed': { toast.show({ - type: 'success', + type: 'info', title: 'Magnet link parsed', timeout: 4000 }); diff --git a/src/App/styles.less b/src/App/styles.less index e6fd7d747..f86057cd0 100644 --- a/src/App/styles.less +++ b/src/App/styles.less @@ -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) { diff --git a/src/common/CONSTANTS.js b/src/common/CONSTANTS.js index 92d009895..104d24a44 100644 --- a/src/common/CONSTANTS.js +++ b/src/common/CONSTANTS.js @@ -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, diff --git a/src/common/Platform/Platform.tsx b/src/common/Platform/Platform.tsx index 0da1881ef..86ca999c6 100644 --- a/src/common/Platform/Platform.tsx +++ b/src/common/Platform/Platform.tsx @@ -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'); diff --git a/src/common/Platform/device.ts b/src/common/Platform/device.ts index dfb5aa690..d80e011ab 100644 --- a/src/common/Platform/device.ts +++ b/src/common/Platform/device.ts @@ -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(); diff --git a/src/common/Shortcuts/onShortcut.ts b/src/common/Shortcuts/onShortcut.ts index f13c970cc..a66786601 100644 --- a/src/common/Shortcuts/onShortcut.ts +++ b/src/common/Shortcuts/onShortcut.ts @@ -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; diff --git a/src/common/Shortcuts/shortcuts.json b/src/common/Shortcuts/shortcuts.json index a3ac0f8fe..659c3c373 100644 --- a/src/common/Shortcuts/shortcuts.json +++ b/src/common/Shortcuts/shortcuts.json @@ -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"]] } ] } diff --git a/src/common/Toast/ToastContext.js b/src/common/Toast/ToastContext.js index 6a5ede356..cefe9071e 100644 --- a/src/common/Toast/ToastContext.js +++ b/src/common/Toast/ToastContext.js @@ -6,6 +6,7 @@ const React = require('react'); const ToastContext = React.createContext({ show: () => { }, + remove: () => { }, clear: () => { } }); diff --git a/src/common/Toast/ToastProvider.js b/src/common/Toast/ToastProvider.js index a9cab9bb4..e375267af 100644 --- a/src/common/Toast/ToastProvider.js +++ b/src/common/Toast/ToastProvider.js @@ -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' }); diff --git a/src/common/animations.less b/src/common/animations.less index 3b9815f14..141dc52e2 100644 --- a/src/common/animations.less +++ b/src/common/animations.less @@ -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 { diff --git a/src/common/comparatorWithPriorities.js b/src/common/comparatorWithPriorities.js deleted file mode 100644 index baa12b843..000000000 --- a/src/common/comparatorWithPriorities.js +++ /dev/null @@ -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; diff --git a/src/common/index.js b/src/common/index.js index f6f2b23fc..f118ec1a7 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -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, diff --git a/src/common/interfaceLanguages.json b/src/common/interfaceLanguages.json index 91b87d5af..18ab79fb6 100644 --- a/src/common/interfaceLanguages.json +++ b/src/common/interfaceLanguages.json @@ -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"] diff --git a/src/common/languages.ts b/src/common/languages.ts index eea073bc7..a2def56cd 100644 --- a/src/common/languages.ts +++ b/src/common/languages.ts @@ -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, }; diff --git a/src/common/usePlayUrl.ts b/src/common/usePlayUrl.ts new file mode 100644 index 000000000..49fe386ed --- /dev/null +++ b/src/common/usePlayUrl.ts @@ -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 => { + 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; diff --git a/src/common/useTorrent.js b/src/common/useTorrent.js index 0ae117d3a..2527154a2 100644 --- a/src/common/useTorrent.js +++ b/src/common/useTorrent.js @@ -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]); diff --git a/src/components/MetaPreview/Ratings/Ratings.less b/src/components/ActionsGroup/ActionsGroup.less similarity index 96% rename from src/components/MetaPreview/Ratings/Ratings.less rename to src/components/ActionsGroup/ActionsGroup.less index ffba0415b..09e903435 100644 --- a/src/components/MetaPreview/Ratings/Ratings.less +++ b/src/components/ActionsGroup/ActionsGroup.less @@ -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 { diff --git a/src/components/ActionsGroup/ActionsGroup.tsx b/src/components/ActionsGroup/ActionsGroup.tsx new file mode 100644 index 000000000..052f25016 --- /dev/null +++ b/src/components/ActionsGroup/ActionsGroup.tsx @@ -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 ( +
+ { + items.map((item, index) => ( +
+ { + item.label && + + } + +
+ )) + } +
+ ); +}; + +export default ActionsGroup; diff --git a/src/components/ActionsGroup/index.ts b/src/components/ActionsGroup/index.ts new file mode 100644 index 000000000..4dea1b83a --- /dev/null +++ b/src/components/ActionsGroup/index.ts @@ -0,0 +1,6 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import ActionsGroup from './ActionsGroup'; + +export default ActionsGroup; + diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index a5756fc42..e1566c5d5 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -10,6 +10,7 @@ type Props = { style?: object, href?: string, target?: string + download?: string, title?: string, disabled?: boolean, tabIndex?: number, diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx index 0f78454e8..1ac72949a 100644 --- a/src/components/ContextMenu/ContextMenu.tsx +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -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[], 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([0, 0]); const [containerSize, setContainerSize] = useState([0, 0]); + const [triggerRect, setTriggerRect] = useState(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(( -
+ return createPortal(( +
- {children} +
+ {children} +
-
+ ), document.body); }; diff --git a/src/components/ContinueWatchingItem/ContinueWatchingItem.js b/src/components/ContinueWatchingItem/ContinueWatchingItem.js index 8a0143619..8e56179df 100644 --- a/src/components/ContinueWatchingItem/ContinueWatchingItem.js +++ b/src/components/ContinueWatchingItem/ContinueWatchingItem.js @@ -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} /> ); diff --git a/src/components/Image/Image.tsx b/src/components/Image/Image.tsx index e64078fbc..5c7a93c00 100644 --- a/src/components/Image/Image.tsx +++ b/src/components/Image/Image.tsx @@ -7,7 +7,7 @@ type Props = { src: string, alt: string, fallbackSrc: string, - renderFallback: () => void, + renderFallback: () => React.ReactNode, onError: (event: React.SyntheticEvent) => void, }; diff --git a/src/components/LibItem/LibItem.js b/src/components/LibItem/LibItem.js index a42def27f..28769ddcf 100644 --- a/src/components/LibItem/LibItem.js +++ b/src/components/LibItem/LibItem.js @@ -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 ( { newVideos={newVideos} options={options} optionOnSelect={optionOnSelect} + onPlayClick={onPlayClick} /> ); }; diff --git a/src/components/MetaItem/MetaItem.js b/src/components/MetaItem/MetaItem.js index 1e19054c1..0e66c6a9e 100644 --- a/src/components/MetaItem/MetaItem.js +++ b/src/components/MetaItem/MetaItem.js @@ -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 : diff --git a/src/components/MetaPreview/MetaLinks/MetaLinks.js b/src/components/MetaPreview/MetaLinks/MetaLinks.js index 3be090911..6e58a5d1e 100644 --- a/src/components/MetaPreview/MetaLinks/MetaLinks.js +++ b/src/components/MetaPreview/MetaLinks/MetaLinks.js @@ -14,7 +14,7 @@ const MetaLinks = ({ className, label, links }) => { { typeof label === 'string' && label.length > 0 ?
- { stringWithPrefix(label.toUpperCase(), 'LINKS') } + { stringWithPrefix(label.toUpperCase(), 'LINKS_') }
: null diff --git a/src/components/MetaPreview/MetaPreview.js b/src/components/MetaPreview/MetaPreview.js index 13717919a..5fa7d8ff0 100644 --- a/src/components/MetaPreview/MetaPreview.js +++ b/src/components/MetaPreview/MetaPreview.js @@ -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(() => (
{name}
), [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 (
{ @@ -195,19 +208,6 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou }
- { - typeof toggleInLibrary === 'function' ? - - : - null - } { typeof trailerHref === 'string' ? + : null + } { typeof showHref === 'string' && compact ? : 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, }; diff --git a/src/components/MetaPreview/Ratings/Ratings.tsx b/src/components/MetaPreview/Ratings/Ratings.tsx index 6bef0cc6d..8ba867800 100644 --- a/src/components/MetaPreview/Ratings/Ratings.tsx +++ b/src/components/MetaPreview/Ratings/Ratings.tsx @@ -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 ( -
-
- -
-
- -
-
+ ); }; diff --git a/src/components/MetaPreview/styles.less b/src/components/MetaPreview/styles.less index 3fea95a5f..3b21c0ed6 100644 --- a/src/components/MetaPreview/styles.less +++ b/src/components/MetaPreview/styles.less @@ -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; + } } } diff --git a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js index 65bf30c94..6fed91c8a 100644 --- a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js +++ b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js @@ -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 }
+ { + hdrInfo && (hdrInfo.gamma === 'pq' || hdrInfo.gamma === 'hlg') ? +
+ +
+ : + null + } { !isIOSPWA && fullscreenButton ? } + { + magnetLink && + + } { downloadLink &&
- {`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 ?? '')}
: @@ -168,17 +170,6 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
: - { - countLoadingAddons > 0 ? -
-
- {countLoadingAddons} {t('MOBILE_ADDONS_LOADING')} -
- -
- : - null - }
{filteredStreams.map((stream, index) => ( { null }
+ { + countLoadingAddons > 0 ? +
+
+ {countLoadingAddons} {t('MOBILE_ADDONS_LOADING')} +
+ +
+ : + null + }
}
diff --git a/src/routes/MetaDetails/StreamsList/styles.less b/src/routes/MetaDetails/StreamsList/styles.less index ecfea5162..e6c7308f8 100644 --- a/src/routes/MetaDetails/StreamsList/styles.less +++ b/src/routes/MetaDetails/StreamsList/styles.less @@ -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); + } } } \ No newline at end of file diff --git a/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js b/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js index 4013e0b64..ed5bd7a57 100644 --- a/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js +++ b/src/routes/MetaDetails/VideosList/SeasonsBar/SeasonsBarPlaceholder/SeasonsBarPlaceholder.js @@ -13,14 +13,14 @@ const SeasonsBarPlaceholder = ({ className }) => {
-
{t('SEASON_PREV')}
+
{t('PREV_SEASON')}
{t('SEASON_NUMBER', { season: 1 })}
-
{t('SEASON_NEXT')}
+
{t('NEXT_SEASON')}
diff --git a/src/routes/MetaDetails/useMetaDetails.js b/src/routes/MetaDetails/useMetaDetails.js index c3790fddb..f86259345 100644 --- a/src/routes/MetaDetails/useMetaDetails.js +++ b/src/routes/MetaDetails/useMetaDetails.js @@ -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, diff --git a/src/routes/MetaDetails/useSeason.js b/src/routes/MetaDetails/useSeason.js index 9d958a5cf..192830a42 100644 --- a/src/routes/MetaDetails/useSeason.js +++ b/src/routes/MetaDetails/useSeason.js @@ -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]; }; diff --git a/src/routes/Player/AudioMenu/AudioMenu.tsx b/src/routes/Player/AudioMenu/AudioMenu.tsx index 3149f1336..87ee1506a 100644 --- a/src/routes/Player/AudioMenu/AudioMenu.tsx +++ b/src/routes/Player/AudioMenu/AudioMenu.tsx @@ -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(({ 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 ( -
+
{ t('AUDIO_TRACKS') } @@ -62,6 +62,6 @@ const AudioMenu = ({ className, selectedAudioTrackId, audioTracks, onAudioTrackS
); -}; +})); export default AudioMenu; diff --git a/src/routes/Player/ControlBar/ControlBar.js b/src/routes/Player/ControlBar/ControlBar.js index ba721036c..c9ba5cd7e 100644 --- a/src/routes/Player/ControlBar/ControlBar.js +++ b/src/routes/Player/ControlBar/ControlBar.js @@ -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 ( -
+
+ + @@ -183,7 +189,7 @@ const ControlBar = ({
); -}; +}); 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, diff --git a/src/routes/Player/Indicator/Indicator.tsx b/src/routes/Player/Indicator/Indicator.tsx index f88682e71..c7591b028 100644 --- a/src/routes/Player/Indicator/Indicator.tsx +++ b/src/routes/Player/Indicator/Indicator.tsx @@ -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 = { + 'contain': 'PLAYER_SCALE_FIT', + 'cover': 'PLAYER_SCALE_CROP', + 'fill': 'PLAYER_SCALE_STRETCH', }; const PROPERTIES: Record = { @@ -15,9 +21,13 @@ const PROPERTIES: Record = { 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; +type VideoState = Record; type Props = { className: string, @@ -28,6 +38,7 @@ type Props = { const Indicator = ({ className, videoState, disabled }: Props) => { const timeout = useRef(null); const prevVideoState = useRef(videoState); + const initialized = useRef>(new Set()); const [shown, show, hide] = useBinaryState(false); const [current, setCurrent] = useState(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 ( - +
{label} {value}
diff --git a/src/routes/Player/OptionsMenu/OptionsMenu.js b/src/routes/Player/OptionsMenu/OptionsMenu.js index da826ba41..cbeeca90e 100644 --- a/src/routes/Player/OptionsMenu/OptionsMenu.js +++ b/src/routes/Player/OptionsMenu/OptionsMenu.js @@ -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 ( -
+
{ streamingUrl || downloadUrl ?
); }; diff --git a/src/routes/Player/SideDrawer/SideDrawer.less b/src/routes/Player/SideDrawer/SideDrawer.less index 0fe22f58a..e6831a71d 100644 --- a/src/routes/Player/SideDrawer/SideDrawer.less +++ b/src/routes/Player/SideDrawer/SideDrawer.less @@ -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; - } } } diff --git a/src/routes/Player/SpeedMenu/SpeedMenu.js b/src/routes/Player/SpeedMenu/SpeedMenu.js index f71ce6116..501020e36 100644 --- a/src/routes/Player/SpeedMenu/SpeedMenu.js +++ b/src/routes/Player/SpeedMenu/SpeedMenu.js @@ -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 ( -
+
{ t('PLAYBACK_SPEED') }
@@ -39,7 +39,7 @@ const SpeedMenu = ({ className, playbackSpeed, onPlaybackSpeedChanged }) => {
); -}; +})); SpeedMenu.propTypes = { className: PropTypes.string, diff --git a/src/routes/Player/SpeedMenu/styles.less b/src/routes/Player/SpeedMenu/styles.less index 4305d5d01..c2a694629 100644 --- a/src/routes/Player/SpeedMenu/styles.less +++ b/src/routes/Player/SpeedMenu/styles.less @@ -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 { diff --git a/src/routes/Player/StatisticsMenu/StatisticsMenu.js b/src/routes/Player/StatisticsMenu/StatisticsMenu.js index b5f5232ea..078f46074 100644 --- a/src/routes/Player/StatisticsMenu/StatisticsMenu.js +++ b/src/routes/Player/StatisticsMenu/StatisticsMenu.js @@ -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 ( -
+
{t('PLAYER_STATISTICS')}
@@ -49,7 +52,7 @@ const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
); -}; +})); StatisticsMenu.propTypes = { className: PropTypes.string, diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less new file mode 100644 index 000000000..600b79658 --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less @@ -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; + } +} diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx new file mode 100644 index 000000000..9bd8f909c --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx @@ -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(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 ( + + : + null + } + {canCopyUrl ? + + : + null + } + {track.addonSubtitleId ? + + : + null + } + + } + + ); +}; + +export default SubtitleVariant; diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts b/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts new file mode 100644 index 000000000..16bda596b --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import SubtitleVariant from './SubtitleVariant'; + +export default SubtitleVariant; diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js index fe690d0c4..a71969bd5 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js +++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js @@ -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 ( -
+
{ t('PLAYER_SUBTITLES_LANGUAGES') }
@@ -175,24 +190,12 @@ const SubtitlesMenu = React.memo((props) => { subtitlesTracksForLanguage.length > 0 ?
{subtitlesTracksForLanguage.map((track, index) => ( - + ))}
: @@ -241,12 +244,14 @@ const SubtitlesMenu = React.memo((props) => {
); -}); +})); 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, diff --git a/src/routes/Player/SubtitlesMenu/styles.less b/src/routes/Player/SubtitlesMenu/styles.less index bed7be75d..b0aa3b051 100644 --- a/src/routes/Player/SubtitlesMenu/styles.less +++ b/src/routes/Player/SubtitlesMenu/styles.less @@ -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); - } - } - } } } diff --git a/src/routes/Player/styles.less b/src/routes/Player/styles.less index 4894791f0..4ded33221 100644 --- a/src/routes/Player/styles.less +++ b/src/routes/Player/styles.less @@ -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: ""; } } diff --git a/src/routes/Player/useMediaSession.ts b/src/routes/Player/useMediaSession.ts new file mode 100644 index 000000000..7a63423bd --- /dev/null +++ b/src/routes/Player/useMediaSession.ts @@ -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; diff --git a/src/routes/Player/useVideo.js b/src/routes/Player/useVideo.js index b3a5d2e39..241a5af00 100644 --- a/src/routes/Player/useVideo.js +++ b/src/routes/Player/useVideo.js @@ -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, }; }; diff --git a/src/routes/Player/videoState.d.ts b/src/routes/Player/videoState.d.ts new file mode 100644 index 000000000..0f8a78c10 --- /dev/null +++ b/src/routes/Player/videoState.d.ts @@ -0,0 +1,3 @@ +type VideoState = { + paused?: boolean; +}; diff --git a/src/routes/Settings/Menu/Menu.tsx b/src/routes/Settings/Menu/Menu.tsx index 33cf41dc0..5bc5208f1 100644 --- a/src/routes/Settings/Menu/Menu.tsx +++ b/src/routes/Settings/Menu/Menu.tsx @@ -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) => { - + }
diff --git a/src/routes/Settings/Player/usePlayerOptions.ts b/src/routes/Settings/Player/usePlayerOptions.ts index 27081817b..69d651e64 100644 --- a/src/routes/Settings/Player/usePlayerOptions.ts +++ b/src/routes/Settings/Player/usePlayerOptions.ts @@ -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 = 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')}`; }, diff --git a/src/routes/Settings/Settings.tsx b/src/routes/Settings/Settings.tsx index 727da9e82..2dd3a793a 100644 --- a/src/routes/Settings/Settings.tsx +++ b/src/routes/Settings/Settings.tsx @@ -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) => { 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(); diff --git a/src/routes/Settings/Streaming/URLsManager/AddItem/AddItem.tsx b/src/routes/Settings/Streaming/URLsManager/AddItem/AddItem.tsx index d0a2e0c41..07e829696 100644 --- a/src/routes/Settings/Streaming/URLsManager/AddItem/AddItem.tsx +++ b/src/routes/Settings/Streaming/URLsManager/AddItem/AddItem.tsx @@ -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) => { @@ -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')} />