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 ( ) => { icon={MusicNote} {...tooltip(t("player.audios"), true)} {...props} + > + {tracks.map((x) => ( + player.selectAudioTrack(x)} + /> + ))} + + ); +}; + +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 ( ) => { icon={SettingsIcon} {...tooltip(t("player.quality"), true)} {...props} - > + > + 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} + /> + );