Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions docs/en/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `#<channel number>` or `#<channel name>` 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=<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

Expand Down Expand Up @@ -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=<channel name>` parameter to the URL to pin the shortcut to a specific channel.
> You can also append `#<channel name>` or `#<channel number>` to the URL to pin the shortcut to a specific channel.

## Channel Aggregation

Expand Down
15 changes: 5 additions & 10 deletions docs/guide/web-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 等其他查询参数会被保留。
播放器在切换频道时会自动更新地址栏,随时复制即可分享当前正在播放的频道。

## 功能特性

Expand Down Expand Up @@ -89,7 +84,7 @@ LG webOS 智能电视同样支持通过内置浏览器使用播放器,并将

> [!NOTE]
> 若通过 `player-page-path` 自定义了播放器路径,请使用实际路径访问后再添加到主屏幕。添加到主屏幕的快捷方式会固定指向添加时的 URL,包括其中的 `r2h-token` 等查询参数(如有)。
> 也可以在 URL 末尾附加 `?channel_name=频道名称` 参数,将快捷方式固定到指定频道。
> 也可以在 URL 末尾附加 `#频道名称` 或 `#频道序号`,将快捷方式固定到指定频道。

## 频道聚合

Expand Down
11 changes: 0 additions & 11 deletions e2e/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
98 changes: 66 additions & 32 deletions web-ui/src/lib/player-deep-link.ts
Original file line number Diff line number Diff line change
@@ -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#<token>` 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=<name>`.
* Keep the address bar in sync with the currently playing channel by rewriting
* the URL to `#<name>`, or `#<id>` 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 `<base href>` injected by the
* server does not affect where `replaceState` writes.
* Other query params (notably `r2h-token`) are preserved. Uses an absolute URL
* so the `<base href>` 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;
}
Expand Down
22 changes: 18 additions & 4 deletions web-ui/src/pages/player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,12 +294,26 @@ function PlayerPage() {
}
}, [currentChannel, playMode]);

// Keep the address bar shareable: rewrite the URL to ?channel_name=<name> on every channel switch.
// Keep the address bar shareable: rewrite the URL to #<name> (or #<id> 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;
Expand Down
Loading