diff --git a/front/bun.lock b/front/bun.lock
index 6c6321c7..f2729624 100644
--- a/front/bun.lock
+++ b/front/bun.lock
@@ -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=="],
diff --git a/front/package.json b/front/package.json
index 1c9b267d..0cbe257e 100644
--- a/front/package.json
+++ b/front/package.json
@@ -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",
diff --git a/front/packages/ui/src/utils.ts b/front/packages/ui/src/utils.ts
index b46a67fc..d8c6fc2f 100644
--- a/front/packages/ui/src/utils.ts
+++ b/front/packages/ui/src/utils.ts
@@ -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();
diff --git a/front/src/track-utils.ts b/front/src/track-utils.ts
new file mode 100644
index 00000000..59122e3b
--- /dev/null
+++ b/front/src/track-utils.ts
@@ -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(" - ");
+ };
+};
diff --git a/front/src/ui/player/controls/bottom-controls.tsx b/front/src/ui/player/controls/bottom-controls.tsx
index 25d78505..5d263cbd 100644
--- a/front/src/ui/player/controls/bottom-controls.tsx
+++ b/front/src/ui/player/controls/bottom-controls.tsx
@@ -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 = ({
-
+
+
{Platform.OS === "web" && }
diff --git a/front/src/ui/player/controls/middle-controls.tsx b/front/src/ui/player/controls/middle-controls.tsx
index a2a9d63f..01c0dcc2 100644
--- a/front/src/ui/player/controls/middle-controls.tsx
+++ b/front/src/ui/player/controls/middle-controls.tsx
@@ -42,7 +42,7 @@ export const MiddleControls = ({
{
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);
diff --git a/front/src/ui/player/controls/tracks-menu.tsx b/front/src/ui/player/controls/tracks-menu.tsx
index d3af1d22..dc38a7ae 100644
--- a/front/src/ui/player/controls/tracks-menu.tsx
+++ b/front/src/ui/player/controls/tracks-menu.tsx
@@ -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>>;
@@ -41,8 +45,19 @@ export const SubtitleMenu = (props: Partial) => {
);
};
-export const AudioMenu = (props: Partial) => {
+export const AudioMenu = ({
+ player,
+ ...props
+}: { player: VideoPlayer } & Partial) => {
const { t } = useTranslation();
+ const getDisplayName = useDisplayName();
+ const rerender = useForceRerender();
+
+ useEvent(player, "onAudioTrackChange", rerender);
+
+ const tracks = player.getAvailableAudioTracks();
+
+ if (tracks.length === 0) return null;
return (
+ );
+};
+
+export const VideoMenu = (props: Partial) => {
+ const { t } = useTranslation();
+
+ return (
+
);
};
+export const PlayModeContext = createContext<
+ ["direct" | "hls", (val: "direct" | "hls") => void]
+>(null!);
+
export const QualityMenu = (props: Partial) => {
const { t } = useTranslation();
+ const [playMode, setPlayMode] = useContext(PlayModeContext);
return (
+ >
+ setPlayMode("direct")}
+ />
+ {/* = 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) => ( */}
+ {/* { */}
+ {/* setPlayMode(PlayMode.Hls); */}
+ {/* hls!.currentLevel = i; */}
+ {/* }} */}
+ {/* /> */}
+ {/* )) */}
+ {/* .reverse()} */}
+
);
};
diff --git a/front/src/ui/player/index.tsx b/front/src/ui/player/index.tsx
index 92e26b36..489f05a9 100644
--- a/front/src/ui/player/index.tsx
+++ b/front/src/ui/player/index.tsx
@@ -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();
+ const [playbackError, setPlaybackError] = useState();
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 })}
/>
-
+
>
);
}
@@ -177,21 +182,23 @@ export const Player = () => {
/>
- x)
- .join(" - ")
- : undefined
- }
- chapters={info?.chapters ?? []}
- previous={data?.previous?.video}
- next={data?.next?.video}
- />
+
+ x)
+ .join(" - ")
+ : undefined
+ }
+ chapters={info?.chapters ?? []}
+ previous={data?.previous?.video}
+ next={data?.next?.video}
+ />
+
);