mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-11-01 19:17:16 -04:00
Add audio track selector
This commit is contained in:
parent
8fea8b1fe7
commit
c5f237771c
@ -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=="],
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
39
front/src/track-utils.ts
Normal 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(" - ");
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user