Add audio track selector

This commit is contained in:
Zoe Roux 2025-10-20 00:18:10 +02:00
parent 8fea8b1fe7
commit c5f237771c
No known key found for this signature in database
10 changed files with 162 additions and 73 deletions

View File

@ -27,6 +27,7 @@
"expo-status-bar": "~3.0.8",
"expo-updates": "~29.0.11",
"i18next-http-backend": "^3.0.2",
"langmap": "^0.0.16",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.1.0",
@ -1004,6 +1005,8 @@
"lan-network": ["lan-network@0.1.7", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ=="],
"langmap": ["langmap@0.0.16", "", {}, "sha512-AtYvBK7BsDvWwnSfmO7CfgeUy7GUT1wK3QX8eKH/Ey/eXodqoHuAtvdQ82hmWD9QVFVKnuiNjym9fGY4qSJeLA=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
"lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
@ -1262,7 +1265,7 @@
"react-native-svg-transformer": ["react-native-svg-transformer@1.5.1", "", { "dependencies": { "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", "path-dirname": "^1.0.2" }, "peerDependencies": { "react-native": ">=0.59.0", "react-native-svg": ">=12.0.0" } }, "sha512-dFvBNR8A9VPum9KCfh+LE49YiJEF8zUSnEFciKQroR/bEOhlPoZA0SuQ0qNk7m2iZl2w59FYjdRe0pMHWMDl0Q=="],
"react-native-video": ["react-native-video@github:zoriya/react-native-video#77df6b8", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.27.2" } }, "zoriya-react-native-video-77df6b8"],
"react-native-video": ["react-native-video@github:zoriya/react-native-video#3f30e52", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.27.2" } }, "zoriya-react-native-video-3f30e52"],
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],

View File

@ -36,6 +36,7 @@
"expo-status-bar": "~3.0.8",
"expo-updates": "~29.0.11",
"i18next-http-backend": "^3.0.2",
"langmap": "^0.0.16",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.1.0",

View File

@ -1,43 +1,4 @@
import type { Subtitle, Track } from "@kyoo/models";
import intl from "langmap";
import { useTranslation } from "react-i18next";
export const useLanguageName = () => {
return (lang: string) => intl[lang]?.nativeName;
};
export const useDisplayName = () => {
const getLanguageName = useLanguageName();
const { t } = useTranslation();
return (sub: Track) => {
const lng = sub.language ? getLanguageName(sub.language) : null;
if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`;
if (lng) return lng;
if (sub.title) return sub.title;
if (sub.index !== null) return `${t("mediainfo.unknown")} (${sub.index})`;
return t("mediainfo.unknown");
};
};
export const useSubtitleName = () => {
const getDisplayName = useDisplayName();
const { t } = useTranslation();
return (sub: Subtitle) => {
const name = getDisplayName(sub);
const attributes = [name];
if (sub.isDefault) attributes.push(t("mediainfo.default"));
if (sub.isForced) attributes.push(t("mediainfo.forced"));
if (sub.isHearingImpaired) attributes.push(t("mediainfo.hearing-impaired"));
if (sub.isExternal) attributes.push(t("mediainfo.external"));
return attributes.join(" - ");
};
};
const seenNativeNames = new Set();

39
front/src/track-utils.ts Normal file
View File

@ -0,0 +1,39 @@
import intl from "langmap";
import { useTranslation } from "react-i18next";
import type { Subtitle } from "./models";
export const useLanguageName = () => {
return (lang: string) => intl[lang]?.nativeName;
};
export const useDisplayName = () => {
const getLanguageName = useLanguageName();
const { t } = useTranslation();
return (sub: { language?: string; title?: string; index?: number }) => {
const lng = sub.language ? getLanguageName(sub.language) : null;
if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`;
if (lng) return lng;
if (sub.title) return sub.title;
if (sub.index !== null) return `${t("mediainfo.unknown")} (${sub.index})`;
return t("mediainfo.unknown");
};
};
export const useSubtitleName = () => {
const getDisplayName = useDisplayName();
const { t } = useTranslation();
return (sub: Subtitle) => {
const name = getDisplayName(sub);
const attributes = [name];
if (sub.isDefault) attributes.push(t("mediainfo.default"));
if (sub.isForced) attributes.push(t("mediainfo.forced"));
if (sub.isHearingImpaired) attributes.push(t("mediainfo.hearing-impaired"));
if (sub.isExternal) attributes.push(t("mediainfo.external"));
return attributes.join(" - ");
};
};

View File

@ -19,7 +19,7 @@ import {
} from "~/primitives";
import { FullscreenButton, PlayButton, VolumeSlider } from "./misc";
import { ProgressBar, ProgressText } from "./progress";
import { AudioMenu, QualityMenu, SubtitleMenu } from "./tracks-menu";
import { AudioMenu, QualityMenu, SubtitleMenu, VideoMenu } from "./tracks-menu";
export const BottomControls = ({
player,
@ -164,7 +164,8 @@ const ControlButtons = ({
</View>
<View {...css({ flexDirection: "row" })}>
<SubtitleMenu {...menuProps} />
<AudioMenu {...menuProps} />
<AudioMenu player={player} {...menuProps} />
<VideoMenu {...menuProps} />
<QualityMenu {...menuProps} />
{Platform.OS === "web" && <FullscreenButton {...spacing} />}
</View>

View File

@ -42,7 +42,7 @@ export const MiddleControls = ({
<IconButton
icon={SkipPrevious}
as={Link}
href={previous}
href={previous ?? ""}
replace
size={ts(4)}
{...css([!previous && { opacity: 0, pointerEvents: "none" }], common)}
@ -51,7 +51,7 @@ export const MiddleControls = ({
<IconButton
icon={SkipNext}
as={Link}
href={next}
href={next ?? ""}
replace
size={ts(4)}
{...css([!next && { opacity: 0, pointerEvents: "none" }], common)}

View File

@ -6,9 +6,9 @@ import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { type ComponentProps, useState, useEffect } from "react";
import { type ComponentProps, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { type PressableProps, View, Platform } from "react-native";
import { type PressableProps, View } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { px, useYoshiki } from "yoshiki/native";
import {

View File

@ -98,7 +98,7 @@ export const ProgressText = ({
const toTimerString = (timer?: number, duration?: number) => {
if (!duration) duration = timer;
if (timer === undefined || Number.isNaN(timer)) return "??:??";
if (timer === undefined || !Number.isFinite(timer)) return "??:??";
const h = Math.floor(timer / 3600);
const min = Math.floor((timer / 60) % 60);

View File

@ -1,9 +1,13 @@
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg";
import type { ComponentProps } from "react";
import VideoSettings from "@material-symbols/svg-400/rounded/video_settings-fill.svg";
import { type ComponentProps, createContext, useContext } from "react";
import { useEvent, type VideoPlayer } from "react-native-video";
import { useTranslation } from "react-i18next";
import { IconButton, Menu, tooltip } from "~/primitives";
import { useDisplayName } from "~/track-utils";
import { useForceRerender } from "yoshiki";
type MenuProps = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
@ -41,8 +45,19 @@ export const SubtitleMenu = (props: Partial<MenuProps>) => {
);
};
export const AudioMenu = (props: Partial<MenuProps>) => {
export const AudioMenu = ({
player,
...props
}: { player: VideoPlayer } & Partial<MenuProps>) => {
const { t } = useTranslation();
const getDisplayName = useDisplayName();
const rerender = useForceRerender();
useEvent(player, "onAudioTrackChange", rerender);
const tracks = player.getAvailableAudioTracks();
if (tracks.length === 0) return null;
return (
<Menu
@ -50,12 +65,39 @@ export const AudioMenu = (props: Partial<MenuProps>) => {
icon={MusicNote}
{...tooltip(t("player.audios"), true)}
{...props}
>
{tracks.map((x) => (
<Menu.Item
key={x.id}
label={getDisplayName({ title: x.label, language: x.language })}
selected={x.selected}
onSelect={() => player.selectAudioTrack(x)}
/>
))}
</Menu>
);
};
export const VideoMenu = (props: Partial<MenuProps>) => {
const { t } = useTranslation();
return (
<Menu
Trigger={IconButton}
icon={VideoSettings}
{...tooltip(t("player.audios"), true)}
{...props}
></Menu>
);
};
export const PlayModeContext = createContext<
["direct" | "hls", (val: "direct" | "hls") => void]
>(null!);
export const QualityMenu = (props: Partial<MenuProps>) => {
const { t } = useTranslation();
const [playMode, setPlayMode] = useContext(PlayModeContext);
return (
<Menu
@ -63,6 +105,41 @@ export const QualityMenu = (props: Partial<MenuProps>) => {
icon={SettingsIcon}
{...tooltip(t("player.quality"), true)}
{...props}
></Menu>
>
<Menu.Item
label={t("player.direct")}
selected={playMode === "direct"}
onSelect={() => setPlayMode("direct")}
/>
{/* <Menu.Item */}
{/* label={ */}
{/* hls?.autoLevelEnabled && hls.currentLevel >= 0 */}
{/* ? `${t("player.auto")} (${levelName(hls.levels[hls.currentLevel], true)})` */}
{/* : t("player.auto") */}
{/* } */}
{/* selected={hls?.autoLevelEnabled && mode === PlayMode.Hls} */}
{/* onSelect={() => { */}
{/* setPlayMode(PlayMode.Hls); */}
{/* if (hls) hls.currentLevel = -1; */}
{/* }} */}
{/* /> */}
{/* {hls?.levels */}
{/* .map((x, i) => ( */}
{/* <Menu.Item */}
{/* key={i.toString()} */}
{/* label={levelName(x)} */}
{/* selected={ */}
{/* mode === PlayMode.Hls && */}
{/* hls!.currentLevel === i && */}
{/* !hls?.autoLevelEnabled */}
{/* } */}
{/* onSelect={() => { */}
{/* setPlayMode(PlayMode.Hls); */}
{/* hls!.currentLevel = i; */}
{/* }} */}
{/* /> */}
{/* )) */}
{/* .reverse()} */}
</Menu>
);
};

View File

@ -2,7 +2,7 @@ import { Stack, useRouter } from "expo-router";
import { Platform, StyleSheet, View } from "react-native";
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
import { entryDisplayNumber } from "~/components/entries";
import { FullVideo, VideoInfo } from "~/models";
import { FullVideo, type KyooError, VideoInfo } from "~/models";
import { ContrastArea, Head } from "~/primitives";
import { useToken } from "~/providers/account-context";
import { useLocalSetting } from "~/providers/settings";
@ -15,6 +15,7 @@ import { toggleFullscreen } from "./controls/misc";
import { Back } from "./controls/back";
import { useYoshiki } from "yoshiki/native";
import { ErrorView } from "../errors";
import { PlayModeContext } from "./controls/tracks-menu";
const clientId = uuidv4();
@ -27,14 +28,19 @@ export const Player = () => {
// TODO: map current entry using entries' duration & the current playtime
const currentEntry = 0;
const entry = data?.entries[currentEntry] ?? data?.entries[0];
const title = entry ? `${entry.name} (${entryDisplayNumber(entry)})` : null;
const title = entry
? entry.kind === "movie"
? entry.name
: `${entry.name} (${entryDisplayNumber(entry)})`
: null;
const { apiUrl, authToken } = useToken();
const [defaultPlayMode] = useLocalSetting<"direct" | "hls">(
"playMode",
"direct",
);
const [playMode, setPlayMode] = useState(defaultPlayMode);
const playModeState = useState(defaultPlayMode);
const [playMode, setPlayMode] = playModeState;
const player = useVideoPlayer(
{
uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`,
@ -50,8 +56,8 @@ export const Player = () => {
: {},
metadata: {
title: title ?? undefined,
description: entry?.description ?? undefined,
artist: data?.show?.name ?? undefined,
description: entry?.description ?? undefined,
imageUri: data?.show?.thumbnail?.high ?? undefined,
},
externalSubtitles: info?.subtitles
@ -124,15 +130,14 @@ export const Player = () => {
};
}, []);
const [playbackError, setPlaybackError] = useState<string | undefined>();
const [playbackError, setPlaybackError] = useState<KyooError | undefined>();
useEvent(player, "onError", (error) => {
console.log("error", error, "code", error.code, "playbackMode", playMode);
if (
error.code === "source/unsupported-content-type" &&
playMode === "direct"
)
setPlayMode("hls");
else setPlaybackError(error);
else setPlaybackError({ status: error.code, message: error.message });
});
const { css } = useYoshiki();
if (error || infoError || playbackError) {
@ -142,7 +147,7 @@ export const Player = () => {
name={data?.show?.name ?? "Error"}
{...css({ position: "relative", bg: (theme) => theme.accent })}
/>
<ErrorView error={error ?? infoError ?? { errors: [playbackError!] }} />
<ErrorView error={error ?? infoError ?? playbackError!} />
</>
);
}
@ -177,21 +182,23 @@ export const Player = () => {
/>
<ContrastArea mode="dark">
<LoadingIndicator player={player} />
<Controls
player={player}
name={data?.show?.name}
poster={data?.show?.poster}
subName={
entry
? [entryDisplayNumber(entry), entry.name]
.filter((x) => x)
.join(" - ")
: undefined
}
chapters={info?.chapters ?? []}
previous={data?.previous?.video}
next={data?.next?.video}
/>
<PlayModeContext.Provider value={playModeState}>
<Controls
player={player}
name={data?.show?.name}
poster={data?.show?.poster}
subName={
entry
? [entryDisplayNumber(entry), entry.name]
.filter((x) => x)
.join(" - ")
: undefined
}
chapters={info?.chapters ?? []}
previous={data?.previous?.video}
next={data?.next?.video}
/>
</PlayModeContext.Provider>
</ContrastArea>
</View>
);