diff --git a/.github/workflows/auto_assign.yml b/.github/workflows/auto_assign.yml new file mode 100644 index 000000000..87e9c8f37 --- /dev/null +++ b/.github/workflows/auto_assign.yml @@ -0,0 +1,65 @@ +name: PR and Issue Workflow +on: + pull_request: + types: [opened, reopened] + issues: + types: [opened] +jobs: + auto-assign-and-label: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + # Auto assign PR to author + - name: Auto Assign PR to Author + if: github.event_name == 'pull_request' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pr = context.payload.pull_request; + if (pr) { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + assignees: [pr.user.login] + }); + console.log(`Assigned PR #${pr.number} to author @${pr.user.login}`); + } + + # Dynamic labeling based on PR/Issue title + - name: Label PRs and Issues + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prTitle = context.payload.pull_request ? context.payload.pull_request.title : context.payload.issue.title; + const issueNumber = context.payload.pull_request ? context.payload.pull_request.number : context.payload.issue.number; + const isIssue = context.payload.issue !== undefined; + const labelMappings = [ + { pattern: /^feat(ure)?/i, label: 'feature' }, + { pattern: /^fix/i, label: 'bug' }, + { pattern: /^refactor/i, label: 'refactor' }, + { pattern: /^chore/i, label: 'chore' }, + { pattern: /^docs?/i, label: 'documentation' }, + { pattern: /^perf(ormance)?/i, label: 'performance' }, + { pattern: /^test/i, label: 'testing' } + ]; + let labelsToAdd = []; + for (const mapping of labelMappings) { + if (mapping.pattern.test(prTitle)) { + labelsToAdd.push(mapping.label); + } + } + if (labelsToAdd.length > 0) { + github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: labelsToAdd + }); + } \ No newline at end of file diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index 54b0dd1bf..296ae88ad 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -1,67 +1,26 @@ { - "applinks": { - "apps": [], - "details": [ - { - "appID": "9EWRZ4QP3J.com.stremio.one", - "paths": [ - "/", - "/#/player/*", - "/#/discover/*", - "/#/detail/*", - "/#/library/*", - "/#/addons/*", - "/#/settings", - "/#/search/*" - ], - "components": [ - { - "/": "/", - "#": "/player/*", - "comment": "Matches deep link for player" - }, - { - "/": "/", - "#": "/discover/*", - "comment": "Matches deep link for discover" - }, - { - "/": "/", - "#": "/detail/*", - "comment": "Matches deep link for detail" - }, - { - "/": "/", - "#": "/library/*", - "comment": "Matches deep link for library" - }, - { - "/": "/", - "#": "/addons/*", - "comment": "Matches deep link for addons" - }, - { - "/": "/", - "#": "/settings", - "comment": "Matches deep link for settings" - }, - { - "/": "/", - "#": "/search/*", - "comment": "Matches deep link for search" - } - ] - } + "applinks": { + "apps": [], + "details": [ + { + "appIDs": [ + "9EWRZ4QP3J.com.stremio.one" + ], + "appID": "9EWRZ4QP3J.com.stremio.one", + "paths": [ + "*" ] - }, - "activitycontinuation": { - "apps": [ - "9EWRZ4QP3J.com.stremio.one" - ] - }, - "webcredentials": { - "apps": [ - "9EWRZ4QP3J.com.stremio.one" - ] - } -} + } + ] + }, + "activitycontinuation": { + "apps": [ + "9EWRZ4QP3J.com.stremio.one" + ] + }, + "webcredentials": { + "apps": [ + "9EWRZ4QP3J.com.stremio.one" + ] + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b27bcca2e..7c005f38a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stremio", - "version": "5.0.0-beta.20", + "version": "5.0.0-beta.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stremio", - "version": "5.0.0-beta.20", + "version": "5.0.0-beta.23", "license": "gpl-2.0", "dependencies": { "@babel/runtime": "7.26.0", @@ -14,7 +14,7 @@ "@stremio/stremio-colors": "5.2.0", "@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz", "@stremio/stremio-icons": "5.4.1", - "@stremio/stremio-video": "0.0.53", + "@stremio/stremio-video": "0.0.60", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -3409,9 +3409,10 @@ ] }, "node_modules/@stremio/stremio-video": { - "version": "0.0.53", - "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.53.tgz", - "integrity": "sha512-hSlk8GqMdk4N8VbcdvduYqWVZsQLgHyU7GfFmd1k+t0pSpDKAhI3C6dohG5Sr09CKCjHa8D1rls+CwMNPXLSGw==", + "version": "0.0.60", + "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.60.tgz", + "integrity": "sha512-RbmSi+Lk+3pb6f2ZkGVCnoMoJoujvVvSLDHiLGkXnzQwjYf2B2022NKlAQmHRuHN1sjD+VEsKD8foQH4hXGG1A==", + "license": "MIT", "dependencies": { "buffer": "6.0.3", "color": "4.2.3", diff --git a/package.json b/package.json index 06e7405a2..c1d7ff2c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stremio", "displayName": "Stremio", - "version": "5.0.0-beta.20", + "version": "5.0.0-beta.23", "author": "Smart Code OOD", "private": true, "license": "gpl-2.0", @@ -18,7 +18,7 @@ "@stremio/stremio-colors": "5.2.0", "@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz", "@stremio/stremio-icons": "5.4.1", - "@stremio/stremio-video": "0.0.53", + "@stremio/stremio-video": "0.0.60", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", diff --git a/src/App/App.js b/src/App/App.js index de8c09cc7..115a69670 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -21,7 +21,6 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router)) const App = () => { const { i18n } = useTranslation(); const shell = useShell(); - const [windowHidden, setWindowHidden] = React.useState(false); const [gamepadSupportEnabled, setGamepadSupportEnabled] = React.useState(false); const onPathNotMatch = React.useCallback(() => { return NotFound; @@ -103,25 +102,25 @@ const App = () => { // Handle shell events React.useEffect(() => { - const onWindowVisibilityChanged = (state) => { - setWindowHidden(state.visible === false && state.visibility === 0); - }; - const onOpenMedia = (data) => { - if (data.startsWith('stremio:///')) return; - if (data.startsWith('stremio://')) { - const transportUrl = data.replace('stremio://', 'https://'); - if (URL.canParse(transportUrl)) { - window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`; + try { + const { protocol, hostname, pathname, searchParams } = new URL(data); + if (protocol === CONSTANTS.PROTOCOL) { + if (hostname.length) { + const transportUrl = `https://${hostname}${pathname}`; + window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`; + } else { + window.location.href = `#${pathname}?${searchParams.toString()}`; + } } + } catch (e) { + console.error('Failed to open media:', e); } }; - shell.on('win-visibility-changed', onWindowVisibilityChanged); shell.on('open-media', onOpenMedia); return () => { - shell.off('win-visibility-changed', onWindowVisibilityChanged); shell.off('open-media', onOpenMedia); }; }, []); @@ -138,7 +137,7 @@ const App = () => { setGamepadSupportEnabled(args.settings.gamepadSupport); } - if (args?.settings?.quitOnClose && windowHidden) { + if (args?.settings?.quitOnClose && shell.windowClosed) { shell.send('quit'); } @@ -155,7 +154,7 @@ const App = () => { setGamepadSupportEnabled(state.profile.settings.gamepadSupport); } - if (state?.profile?.settings?.quitOnClose && windowHidden) { + if (state?.profile?.settings?.quitOnClose && shell.windowClosed) { shell.send('quit'); } }; @@ -200,7 +199,7 @@ const App = () => { services.core.transport.off('CoreEvent', onCoreEvent); } }; - }, [initialized, windowHidden]); + }, [initialized, shell.windowClosed]); return ( diff --git a/src/App/styles.less b/src/App/styles.less index 50819f883..373b46900 100644 --- a/src/App/styles.less +++ b/src/App/styles.less @@ -151,14 +151,13 @@ svg { html { width: @html-width; height: @html-height; - font-family: 'PlusJakartaSans', 'sans-serif'; + font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'sans-serif'; overflow: auto; overscroll-behavior: none; user-select: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; - @media (display-mode: standalone) { width: @html-standalone-width; height: @html-standalone-height; @@ -168,6 +167,7 @@ html { width: 100%; height: 100%; background: linear-gradient(41deg, var(--primary-background-color) 0%, var(--secondary-background-color) 100%); + -webkit-font-smoothing: antialiased; :global(#app) { position: relative; diff --git a/src/common/CONSTANTS.js b/src/common/CONSTANTS.js index 8e4e3efdc..92d009895 100644 --- a/src/common/CONSTANTS.js +++ b/src/common/CONSTANTS.js @@ -106,6 +106,8 @@ const EXTERNAL_PLAYERS = [ const WHITELISTED_HOSTS = ['stremio.com', 'strem.io', 'stremio.zendesk.com', 'google.com', 'youtube.com', 'twitch.tv', 'twitter.com', 'x.com', 'netflix.com', 'adex.network', 'amazon.com', 'forms.gle']; +const PROTOCOL = 'stremio:'; + module.exports = { CHROMECAST_RECEIVER_APP_ID, DEFAULT_STREAMING_SERVER_URL, @@ -127,4 +129,5 @@ module.exports = { SUPPORTED_LOCAL_SUBTITLES, EXTERNAL_PLAYERS, WHITELISTED_HOSTS, + PROTOCOL, }; diff --git a/src/common/Platform/Platform.tsx b/src/common/Platform/Platform.tsx index 2212303e4..0da1881ef 100644 --- a/src/common/Platform/Platform.tsx +++ b/src/common/Platform/Platform.tsx @@ -1,6 +1,5 @@ import React, { createContext, useContext } from 'react'; import { WHITELISTED_HOSTS } from 'stremio/common/CONSTANTS'; -import useShell from 'stremio/common/useShell'; import { name, isMobile } from './device'; interface PlatformContext { @@ -16,19 +15,13 @@ type Props = { }; const PlatformProvider = ({ children }: Props) => { - const shell = useShell(); - const openExternal = (url: string) => { try { const { hostname } = new URL(url); const isWhitelisted = WHITELISTED_HOSTS.some((host: string) => hostname.endsWith(host)); const finalUrl = !isWhitelisted ? `https://www.stremio.com/warning#${encodeURIComponent(url)}` : url; - if (shell.active) { - shell.send('open-external', finalUrl); - } else { - window.open(finalUrl, '_blank'); - } + window.open(finalUrl, '_blank'); } catch (e) { console.error('Failed to parse external url:', e); } diff --git a/src/common/routesRegexp.js b/src/common/routesRegexp.js index 3903da44b..b9989b4b5 100644 --- a/src/common/routesRegexp.js +++ b/src/common/routesRegexp.js @@ -6,7 +6,7 @@ const routesRegexp = { urlParamsNames: [] }, board: { - regexp: /^\/?$/, + regexp: /^\/?(?:board)?$/, urlParamsNames: [] }, discover: { diff --git a/src/common/useFullscreen.ts b/src/common/useFullscreen.ts index d81003266..9bd5d0fc5 100644 --- a/src/common/useFullscreen.ts +++ b/src/common/useFullscreen.ts @@ -1,7 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 import { useCallback, useEffect, useState } from 'react'; -import useShell, { type WindowVisibilityState } from './useShell'; +import useShell, { type WindowVisibility } from './useShell'; import useSettings from './useSettings'; const useFullscreen = () => { @@ -22,7 +22,9 @@ const useFullscreen = () => { if (shell.active) { shell.send('win-set-visibility', { fullscreen: false }); } else { - document.exitFullscreen(); + if (document.fullscreenElement === document.documentElement) { + document.exitFullscreen(); + } } }, []); @@ -31,7 +33,7 @@ const useFullscreen = () => { }, [fullscreen]); useEffect(() => { - const onWindowVisibilityChanged = (state: WindowVisibilityState) => { + const onWindowVisibilityChanged = (state: WindowVisibility) => { setFullscreen(state.isFullscreen === true); }; @@ -44,6 +46,10 @@ const useFullscreen = () => { exitFullscreen(); } + if (event.code === 'KeyF') { + toggleFullscreen(); + } + if (event.code === 'F11' && shell.active) { toggleFullscreen(); } @@ -58,7 +64,7 @@ const useFullscreen = () => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('fullscreenchange', onFullscreenChange); }; - }, [settings.escExitFullscreen]); + }, [settings.escExitFullscreen, toggleFullscreen]); return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen]; }; diff --git a/src/common/useShell.ts b/src/common/useShell.ts index 7ef7ce0a4..0471e38ab 100644 --- a/src/common/useShell.ts +++ b/src/common/useShell.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import EventEmitter from 'eventemitter3'; const SHELL_EVENT_OBJECT = 'transport'; @@ -17,13 +17,22 @@ type ShellEvent = { args: string[]; }; -export type WindowVisibilityState = { +export type WindowVisibility = { + visible: boolean; + visibility: number; isFullscreen: boolean; }; +export type WindowState = { + state: number; +}; + const createId = () => Math.floor(Math.random() * 9999) + 1; const useShell = () => { + const [windowClosed, setWindowClosed] = useState(false); + const [windowHidden, setWindowHidden] = useState(false); + const on = (name: string, listener: (arg: any) => void) => { events.on(name, listener); }; @@ -46,6 +55,24 @@ const useShell = () => { } }; + useEffect(() => { + const onWindowVisibilityChanged = (data: WindowVisibility) => { + setWindowClosed(data.visible === false && data.visibility === 0); + }; + + const onWindowStateChanged = (data: WindowState) => { + setWindowHidden(data.state === 9); + }; + + on('win-visibility-changed', onWindowVisibilityChanged); + on('win-state-changed', onWindowStateChanged); + + return () => { + off('win-visibility-changed', onWindowVisibilityChanged); + off('win-state-changed', onWindowStateChanged); + }; + }, []); + useEffect(() => { if (!transport) return; @@ -70,6 +97,8 @@ const useShell = () => { send, on, off, + windowClosed, + windowHidden, }; }; diff --git a/src/components/MetaPreview/MetaPreview.js b/src/components/MetaPreview/MetaPreview.js index c4eb47c0a..c0e9fb165 100644 --- a/src/components/MetaPreview/MetaPreview.js +++ b/src/components/MetaPreview/MetaPreview.js @@ -24,7 +24,7 @@ const ALLOWED_LINK_REDIRECTS = [ routesRegexp.metadetails.regexp ]; -const MetaPreview = ({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary }) => { +const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary }, ref) => { const { t } = useTranslation(); const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false); const linksGroups = React.useMemo(() => { @@ -98,7 +98,7 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
{name}
), [name]); return ( -
+
{ typeof background === 'string' && background.length > 0 ?
@@ -261,7 +261,7 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
); -}; +}); MetaPreview.Placeholder = MetaPreviewPlaceholder; diff --git a/src/components/Multiselect/Multiselect.js b/src/components/Multiselect/Multiselect.js index c791c60d1..0e353eef4 100644 --- a/src/components/Multiselect/Multiselect.js +++ b/src/components/Multiselect/Multiselect.js @@ -10,16 +10,16 @@ const ModalDialog = require('stremio/components/ModalDialog'); const useBinaryState = require('stremio/common/useBinaryState'); const styles = require('./styles'); -const Multiselect = ({ className, mode, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => { +const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => { const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false); - const options = React.useMemo(() => { - return Array.isArray(props.options) ? - props.options.filter((option) => { + const filteredOptions = React.useMemo(() => { + return Array.isArray(options) ? + options.filter((option) => { return option && (typeof option.value === 'string' || option.value === null); }) : []; - }, [props.options]); + }, [options]); const selected = React.useMemo(() => { return Array.isArray(props.selected) ? props.selected.filter((value) => { @@ -94,7 +94,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren : selected.length > 0 ? selected.map((value) => { - const option = options.find((option) => option.value === value); + const option = filteredOptions.find((option) => option.value === value); return option && typeof option.label === 'string' ? option.label : @@ -109,12 +109,12 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren } {children} - ), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]); + ), [menuOpen, title, disabled, filteredOptions, selected, labelOnClick, renderLabelContent, renderLabelText]); const renderMenu = React.useCallback(() => (
{ - options.length > 0 ? - options.map(({ label, title, value }) => ( + filteredOptions.length > 0 ? + filteredOptions.map(({ label, title, value }) => (
- ), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]); + ), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]); const renderPopupLabel = React.useMemo(() => (labelProps) => { return renderLabel({ ...labelProps, diff --git a/src/components/MultiselectMenu/Dropdown/Dropdown.tsx b/src/components/MultiselectMenu/Dropdown/Dropdown.tsx index 3da75619b..411ef6ab5 100644 --- a/src/components/MultiselectMenu/Dropdown/Dropdown.tsx +++ b/src/components/MultiselectMenu/Dropdown/Dropdown.tsx @@ -10,23 +10,25 @@ import styles from './Dropdown.less'; type Props = { options: MultiselectMenuOption[]; - selectedOption?: MultiselectMenuOption | null; + value?: string | number; menuOpen: boolean | (() => void); level: number; setLevel: (level: number) => void; - onSelect: (value: number) => void; + onSelect: (value: string | number) => void; }; -const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen }: Props) => { +const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props) => { const { t } = useTranslation(); const optionsRef = useRef(new Map()); const containerRef = useRef(null); - const handleSetOptionRef = useCallback((value: number) => (node: HTMLButtonElement | null) => { + const selectedOption = options.find((opt) => opt.value === value); + + const handleSetOptionRef = useCallback((optionValue: string | number) => (node: HTMLButtonElement | null) => { if (node) { - optionsRef.current.set(value, node); + optionsRef.current.set(optionValue, node); } else { - optionsRef.current.delete(value); + optionsRef.current.delete(optionValue); } }, []); @@ -63,11 +65,11 @@ const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen .filter((option: MultiselectMenuOption) => !option.hidden) .map((option: MultiselectMenuOption) => (