diff --git a/docs/en/guide/web-player.md b/docs/en/guide/web-player.md index 20b92083..66f1ec65 100644 --- a/docs/en/guide/web-player.md +++ b/docs/en/guide/web-player.md @@ -20,19 +20,14 @@ The player page path can be customized via the `player-page-path` configuration ## Deep Links to Channels -You can open a specific channel directly by appending query parameters to the player URL, which is useful for bookmarks or sharing with others: +You can append `#` or `#` to the player URL to open a specific channel, which is useful for bookmarks or sharing with others: ```url -http://server:port/player?channel_id=1 -http://server:port/player?channel_name=cctv1 +http://server:port/player#5 +http://server:port/player#CCTV-1 ``` -- `channel_id`: matches the channel by its position number in the list (the channel number shown in the channel list). **Note**: the position number is the channel's place in the playlist and may change when channels are added, removed, or reordered. -- `channel_name`: matches the channel name exactly, case-insensitively and ignoring leading/trailing whitespace. Channel names are usually more stable and are the recommended parameter. -- When both parameters are present, `channel_id` takes precedence. -- If neither parameter matches a channel, the player falls back to the default behavior (the last played channel, or the first channel in the list). - -The player automatically updates the address bar to `?channel_name=` when you switch channels, so you can copy the address bar link at any time to share the channel currently playing. Other query parameters such as `r2h-token` are preserved. +The player automatically updates the address bar when you switch channels, so you can copy the link at any time to share the channel currently playing. ## Features @@ -89,7 +84,7 @@ LG webOS smart TVs can also use the built-in browser to open the player and pin > [!NOTE] > If you customized the player path via `player-page-path`, use the actual path when adding to the home screen. The shortcut is pinned to the URL used at the time of adding, including query parameters such as `r2h-token` (if present). -> You can also append the `?channel_name=` parameter to the URL to pin the shortcut to a specific channel. +> You can also append `#` or `#` to the URL to pin the shortcut to a specific channel. ## Channel Aggregation diff --git a/docs/guide/web-player.md b/docs/guide/web-player.md index 5dee8bfa..94f721eb 100644 --- a/docs/guide/web-player.md +++ b/docs/guide/web-player.md @@ -20,19 +20,14 @@ http://192.168.1.1:5140/player ## 链接直达频道 -可以通过在播放器地址后附加查询参数,直接打开指定频道,适合收藏或分享给他人: +可以在播放器地址后附加 `#频道序号` 或 `#频道名称`,直接打开指定频道,适合收藏或分享给他人: ```url -http://服务器地址:端口/player?channel_id=1 -http://服务器地址:端口/player?channel_name=cctv1 +http://服务器地址:端口/player#5 +http://服务器地址:端口/player#CCTV-1 ``` -- `channel_id`:按频道在列表中的序号匹配(即频道列表中显示的频道号)。**注意**:序号是频道在播放列表中的位置,当播放列表增删或调整顺序后,序号可能发生变化。 -- `channel_name`:按频道名称精确匹配,不区分大小写,首尾空白会被忽略。频道名称通常更稳定,是推荐使用的参数。 -- 两个参数同时存在时,`channel_id` 优先。 -- 参数未匹配到任何频道时,播放器会回退到默认行为(上次播放的频道,或列表中的第一个频道)。 - -播放器在切换频道时会自动将地址栏更新为 `?channel_name=频道名称` 的形式,因此随时复制地址栏链接即可分享当前正在播放的频道。`r2h-token` 等其他查询参数会被保留。 +播放器在切换频道时会自动更新地址栏,随时复制即可分享当前正在播放的频道。 ## 功能特性 @@ -89,7 +84,7 @@ LG webOS 智能电视同样支持通过内置浏览器使用播放器,并将 > [!NOTE] > 若通过 `player-page-path` 自定义了播放器路径,请使用实际路径访问后再添加到主屏幕。添加到主屏幕的快捷方式会固定指向添加时的 URL,包括其中的 `r2h-token` 等查询参数(如有)。 -> 也可以在 URL 末尾附加 `?channel_name=频道名称` 参数,将快捷方式固定到指定频道。 +> 也可以在 URL 末尾附加 `#频道名称` 或 `#频道序号`,将快捷方式固定到指定频道。 ## 频道聚合 diff --git a/e2e/test_pages.py b/e2e/test_pages.py index 83ed73e4..9e8ca472 100644 --- a/e2e/test_pages.py +++ b/e2e/test_pages.py @@ -155,17 +155,6 @@ def test_player_returns_html(self, basic_r2h): assert "html" in ct.lower() assert len(body) > 100 - @pytest.mark.parametrize( - "deep_link", - ["/player?channel_id=1", "/player?channel_name=CCTV1", "/player?channel_id=1&channel_name=CCTV1"], - ) - def test_player_serves_html_with_channel_deep_link_params(self, basic_r2h, deep_link): - status, hdrs, body = http_get("127.0.0.1", basic_r2h.port, deep_link) - assert status == 200 - ct = get_header(hdrs, "Content-Type") - assert "html" in ct.lower() - assert len(body) > 100 - def test_player_contains_install_metadata(self, basic_r2h): _, _, body = http_get("127.0.0.1", basic_r2h.port, "/player", headers={"Accept-Encoding": "identity"}) text = body.decode("utf-8") diff --git a/web-ui/src/lib/player-deep-link.ts b/web-ui/src/lib/player-deep-link.ts index 2032150b..b0117729 100644 --- a/web-ui/src/lib/player-deep-link.ts +++ b/web-ui/src/lib/player-deep-link.ts @@ -1,56 +1,90 @@ import type { Channel } from "../types/player"; -export const CHANNEL_ID_PARAM = "channel_id"; -export const CHANNEL_NAME_PARAM = "channel_name"; +function readHashToken(): string { + const raw = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash; + if (!raw) { + return ""; + } + try { + return decodeURIComponent(raw).trim(); + } catch { + return raw.trim(); + } +} + +function findChannelById(channels: Channel[], channelId: string): Channel | undefined { + return channels.find((channel) => channel.id === channelId); +} + +function findChannelByName(channels: Channel[], channelName: string): Channel | undefined { + const normalized = channelName.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + return channels.find((channel) => channel.name.trim().toLowerCase() === normalized); +} /** - * Resolve the deep-link target channel from the page URL query params. + * Resolve the deep-link target channel from the page URL hash. * - * Supports: - * - `channel_id`: exact match against the channel's 1-based list position id - * - `channel_name`: trimmed, case-insensitive match against the channel name + * Supports `/player#` where token is either: + * - a channel id (1-based list position shown in the channel list), or + * - a channel name (trimmed, case-insensitive) * - * `channel_id` wins when both params are present. Returns `undefined` when the - * params are absent or do not match any channel. + * Id match wins when the token matches both an id and a name. Returns + * `undefined` when the token is absent or does not match any channel. */ export function findDeepLinkChannel(channels: Channel[]): Channel | undefined { - const searchParams = new URLSearchParams(window.location.search); + const token = readHashToken(); + if (!token) { + return undefined; + } + return findChannelById(channels, token) ?? findChannelByName(channels, token); +} - const channelId = searchParams.get(CHANNEL_ID_PARAM); - if (channelId) { - const channelById = channels.find((channel) => channel.id === channelId); - if (channelById) { - return channelById; - } +function channelNameIsAmbiguous(channel: Channel, channels: Channel[]): boolean { + const name = channel.name.trim(); + if (!name) { + return true; } - const channelName = searchParams.get(CHANNEL_NAME_PARAM)?.trim().toLowerCase(); - if (channelName) { - const channelByName = channels.find((channel) => channel.name.trim().toLowerCase() === channelName); - if (channelByName) { - return channelByName; + const nameKey = name.toLowerCase(); + let sameNameCount = 0; + for (const other of channels) { + if (other.name.trim().toLowerCase() === nameKey) { + sameNameCount++; + if (sameNameCount > 1) { + return true; + } } } - return undefined; + // `#5` is always parsed as channel id 5, so a unique name that equals some + // channel id would round-trip to the wrong channel if written as a name. + return channels.some((other) => other.id === name); +} + +/** Hash token to write for `channel`: name by default, id when the name would be ambiguous. */ +export function channelDeepLinkToken(channel: Channel, channels: Channel[]): string { + if (channelNameIsAmbiguous(channel, channels)) { + return channel.id; + } + return channel.name.trim(); } /** - * Keep the address bar in sync with the currently playing channel by - * rewriting the URL to `?channel_name=`. + * Keep the address bar in sync with the currently playing channel by rewriting + * the URL to `#`, or `#` when the name is ambiguous. * - * The `channel_id` param is dropped so a stale positional id cannot win over - * the channel name on a later reload. All other params (notably `r2h-token`) - * are preserved. Uses an absolute URL so the `` injected by the - * server does not affect where `replaceState` writes. + * Other query params (notably `r2h-token`) are preserved. Uses an absolute URL + * so the `` injected by the server does not affect where + * `replaceState` writes. */ -export function syncChannelDeepLink(channelName: string): void { +export function syncChannelDeepLink(channel: Channel, channels: Channel[]): void { try { const url = new URL(window.location.href); - url.searchParams.delete(CHANNEL_ID_PARAM); - url.searchParams.set(CHANNEL_NAME_PARAM, channelName); - - const nextUrl = url.toString(); + const token = channelDeepLinkToken(channel, channels); + const nextUrl = `${url.origin}${url.pathname}${url.search}#${encodeURIComponent(token)}`; if (nextUrl === window.location.href) { return; } diff --git a/web-ui/src/pages/player.tsx b/web-ui/src/pages/player.tsx index 865ace00..d45aade3 100644 --- a/web-ui/src/pages/player.tsx +++ b/web-ui/src/pages/player.tsx @@ -294,12 +294,26 @@ function PlayerPage() { } }, [currentChannel, playMode]); - // Keep the address bar shareable: rewrite the URL to ?channel_name= on every channel switch. + // Keep the address bar shareable: rewrite the URL to # (or # if the name is ambiguous). useEffect(() => { - if (currentChannel) { - syncChannelDeepLink(currentChannel.name); + if (currentChannel && metadata) { + syncChannelDeepLink(currentChannel, metadata.channels); } - }, [currentChannel]); + }, [currentChannel, metadata]); + + useEffect(() => { + if (!metadata) return; + + const onHashChange = () => { + const channel = findDeepLinkChannel(metadata.channels); + if (channel && channel.id !== currentChannel?.id) { + selectChannel(channel); + } + }; + + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, [metadata, currentChannel, selectChannel]); const handleCurrentVideoTimeChange = useCallback((time: number) => { currentVideoTimeRef.current = time;