Add keyboard bindings for player

This commit is contained in:
Zoe Roux 2025-10-20 02:01:55 +02:00
parent 70ff2285d5
commit dfdeca35f3
No known key found for this signature in database
5 changed files with 190 additions and 509 deletions

View File

@ -1,6 +1,9 @@
import { Stack, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { Platform, StyleSheet, View } from "react-native";
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
import { v4 as uuidv4 } from "uuid";
import { useYoshiki } from "yoshiki/native";
import { entryDisplayNumber } from "~/components/entries";
import { FullVideo, type KyooError, VideoInfo } from "~/models";
import { ContrastArea, Head } from "~/primitives";
@ -8,14 +11,12 @@ import { useToken } from "~/providers/account-context";
import { useLocalSetting } from "~/providers/settings";
import { type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils";
import { Controls, LoadingIndicator } from "./controls";
import { useEffect, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { toggleFullscreen } from "./controls/misc";
import { Back } from "./controls/back";
import { useYoshiki } from "yoshiki/native";
import { ErrorView } from "../errors";
import { Controls, LoadingIndicator } from "./controls";
import { Back } from "./controls/back";
import { toggleFullscreen } from "./controls/misc";
import { PlayModeContext } from "./controls/tracks-menu";
import { useKeyboard } from "./keyboard";
const clientId = uuidv4();
@ -83,44 +84,38 @@ export const Player = () => {
);
const router = useRouter();
const playPrev = useCallback(() => {
if (!data?.previous) return false;
setStart(0);
setSlug(data.previous.video);
return true;
}, [data?.previous, setSlug, setStart]);
const playNext = useCallback(() => {
if (!data?.next) return false;
setStart(0);
setSlug(data.next.video);
return true;
}, [data?.next, setSlug, setStart]);
useEvent(player, "onEnd", () => {
if (!data) return;
if (data.next) {
setStart(0);
setSlug(data.next.video);
} else {
router.navigate(data.show!.href);
}
const hasNext = playNext();
if (!hasNext && data?.show) router.navigate(data.show.href);
});
// TODO: add the equivalent of this for android
useEffect(() => {
if (typeof window === "undefined") return;
const prev = data?.previous?.video;
window.navigator.mediaSession.setActionHandler(
"previoustrack",
prev
? () => {
setStart(0);
setSlug(prev);
}
: null,
data?.previous?.video ? playPrev : null,
);
const next = data?.next?.video;
window.navigator.mediaSession.setActionHandler(
"nexttrack",
next
? () => {
setStart(0);
setSlug(next);
}
: null,
data?.next?.video ? playNext : null,
);
}, [data?.next?.video, data?.previous?.video, setSlug, setStart]);
}, [data?.next?.video, data?.previous?.video, playNext, playPrev]);
// useVideoKeyboard(info?.subtitles, info?.fonts, previous, next);
// const startTime = startTimeP ?? data?.watchStatus?.watchedTime;
useKeyboard(player, playPrev, playNext);
useEffect(() => {
if (Platform.OS !== "web") return;

View File

@ -0,0 +1,164 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import type { VideoPlayer } from "react-native-video";
import type { Subtitle } from "~/models";
import { toggleFullscreen } from "./controls/misc";
type Action =
| { type: "play" }
| { type: "mute" }
| { type: "fullscreen" }
| { type: "seek"; value: number }
| { type: "seekTo"; value: number }
| { type: "seekPercent"; value: number }
| { type: "volume"; value: number }
| { type: "subtitle"; subtitles: Subtitle[]; fonts: string[] };
const reducer = (player: VideoPlayer, action: Action) => {
switch (action.type) {
case "play":
if (player.isPlaying) player.pause();
else player.play();
break;
case "mute":
player.muted = !player.muted;
break;
case "fullscreen":
toggleFullscreen();
break;
case "seek":
player.seekBy(action.value);
break;
case "seekTo":
player.seekTo(action.value);
break;
case "seekPercent":
player.seekTo((player.duration * action.value) / 100);
break;
case "volume":
player.volume = Math.max(0, Math.min(player.volume + action.value, 100));
break;
// case "subtitle": {
// const subtitle = get(subtitleAtom);
// const index = subtitle
// ? action.subtitles.findIndex((x) => x.index === subtitle.index)
// : -1;
// set(
// subtitleAtom,
// index === -1
// ? null
// : action.subtitles[(index + 1) % action.subtitles.length],
// );
// break;
// }
}
};
export const useKeyboard = (
player: VideoPlayer,
playPrev: () => void,
playNext: () => void,
// subtitles?: Subtitle[],
// fonts?: string[],
) => {
useEffect(() => {
if (Platform.OS !== "web") return;
const handler = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
switch (event.key) {
case " ":
case "k":
case "MediaPlay":
case "MediaPause":
case "MediaPlayPause":
reducer(player, { type: "play" });
break;
case "m":
reducer(player, { type: "mute" });
break;
case "ArrowLeft":
reducer(player, { type: "seek", value: -5 });
break;
case "ArrowRight":
reducer(player, { type: "seek", value: +5 });
break;
case "j":
reducer(player, { type: "seek", value: -10 });
break;
case "l":
reducer(player, { type: "seek", value: +10 });
break;
case "ArrowUp":
reducer(player, { type: "volume", value: +.05 });
break;
case "ArrowDown":
reducer(player, { type: "volume", value: -.05 });
break;
case "f":
reducer(player, { type: "fullscreen" });
break;
// case "v":
// case "c":
// if (!subtitles || !fonts) return;
// reducer(player, { type: "subtitle", subtitles, fonts });
// break;
case "n":
case "N":
playNext();
break;
case "p":
case "P":
playPrev();
break;
default:
break;
}
switch (event.code) {
case "Digit0":
reducer(player, { type: "seekPercent", value: 0 });
break;
case "Digit1":
reducer(player, { type: "seekPercent", value: 10 });
break;
case "Digit2":
reducer(player, { type: "seekPercent", value: 20 });
break;
case "Digit3":
reducer(player, { type: "seekPercent", value: 30 });
break;
case "Digit4":
reducer(player, { type: "seekPercent", value: 40 });
break;
case "Digit5":
reducer(player, { type: "seekPercent", value: 50 });
break;
case "Digit6":
reducer(player, { type: "seekPercent", value: 60 });
break;
case "Digit7":
reducer(player, { type: "seekPercent", value: 70 });
break;
case "Digit8":
reducer(player, { type: "seekPercent", value: 80 });
break;
case "Digit9":
reducer(player, { type: "seekPercent", value: 90 });
break;
}
};
document.addEventListener("keyup", handler);
return () => document.removeEventListener("keyup", handler);
}, [player, playPrev, playNext]);
};

View File

@ -1,191 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Subtitle } from "@kyoo/models";
import { atom, useSetAtom } from "jotai";
import { useEffect } from "react";
import { Platform } from "react-native";
import { useRouter } from "solito/router";
import {
durationAtom,
fullscreenAtom,
mutedAtom,
playAtom,
progressAtom,
subtitleAtom,
volumeAtom,
} from "./old/statee";
type Action =
| { type: "play" }
| { type: "mute" }
| { type: "fullscreen" }
| { type: "seek"; value: number }
| { type: "seekTo"; value: number }
| { type: "seekPercent"; value: number }
| { type: "volume"; value: number }
| { type: "subtitle"; subtitles: Subtitle[]; fonts: string[] };
export const reducerAtom = atom(null, (get, set, action: Action) => {
const duration = get(durationAtom);
switch (action.type) {
case "play":
set(playAtom, !get(playAtom));
break;
case "mute":
set(mutedAtom, !get(mutedAtom));
break;
case "fullscreen":
set(fullscreenAtom, !get(fullscreenAtom));
break;
case "seek":
if (duration)
set(progressAtom, Math.max(0, Math.min(get(progressAtom) + action.value, duration)));
break;
case "seekTo":
set(progressAtom, action.value);
break;
case "seekPercent":
if (duration) set(progressAtom, (duration * action.value) / 100);
break;
case "volume":
set(volumeAtom, Math.max(0, Math.min(get(volumeAtom) + action.value, 100)));
break;
case "subtitle": {
const subtitle = get(subtitleAtom);
const index = subtitle ? action.subtitles.findIndex((x) => x.index === subtitle.index) : -1;
set(
subtitleAtom,
index === -1 ? null : action.subtitles[(index + 1) % action.subtitles.length],
);
break;
}
}
});
export const useVideoKeyboard = (
subtitles?: Subtitle[],
fonts?: string[],
previousEpisode?: string,
nextEpisode?: string,
) => {
const reducer = useSetAtom(reducerAtom);
const router = useRouter();
useEffect(() => {
if (Platform.OS !== "web") return;
const handler = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
switch (event.key) {
case " ":
case "k":
case "MediaPlay":
case "MediaPause":
case "MediaPlayPause":
reducer({ type: "play" });
break;
case "m":
reducer({ type: "mute" });
break;
case "ArrowLeft":
reducer({ type: "seek", value: -5 });
break;
case "ArrowRight":
reducer({ type: "seek", value: +5 });
break;
case "j":
reducer({ type: "seek", value: -10 });
break;
case "l":
reducer({ type: "seek", value: +10 });
break;
case "ArrowUp":
reducer({ type: "volume", value: +5 });
break;
case "ArrowDown":
reducer({ type: "volume", value: -5 });
break;
case "f":
reducer({ type: "fullscreen" });
break;
case "v":
case "c":
if (!subtitles || !fonts) return;
reducer({ type: "subtitle", subtitles, fonts });
break;
case "n":
case "N":
if (nextEpisode) router.push(nextEpisode);
break;
case "p":
case "P":
if (previousEpisode) router.push(previousEpisode);
break;
default:
break;
}
switch (event.code) {
case "Digit0":
reducer({ type: "seekPercent", value: 0 });
break;
case "Digit1":
reducer({ type: "seekPercent", value: 10 });
break;
case "Digit2":
reducer({ type: "seekPercent", value: 20 });
break;
case "Digit3":
reducer({ type: "seekPercent", value: 30 });
break;
case "Digit4":
reducer({ type: "seekPercent", value: 40 });
break;
case "Digit5":
reducer({ type: "seekPercent", value: 50 });
break;
case "Digit6":
reducer({ type: "seekPercent", value: 60 });
break;
case "Digit7":
reducer({ type: "seekPercent", value: 70 });
break;
case "Digit8":
reducer({ type: "seekPercent", value: 80 });
break;
case "Digit9":
reducer({ type: "seekPercent", value: 90 });
break;
}
};
document.addEventListener("keyup", handler);
return () => document.removeEventListener("keyup", handler);
}, [subtitles, fonts, nextEpisode, previousEpisode, router, reducer]);
};

View File

@ -1,88 +0,0 @@
import {
IconButton,
Link,
noTouch,
tooltip,
touchOnly,
ts,
} from "@kyoo/primitives";
import { useAtom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { px, type Stylable, useYoshiki } from "yoshiki/native";
import { HoverTouch, hoverAtom } from ".";
import { playAtom } from "../old/state";
export const TouchControls = ({
previousSlug,
nextSlug,
...props
}: {
previousSlug?: string | null;
nextSlug?: string | null;
}) => {
const { css } = useYoshiki();
const [isPlaying, setPlay] = useAtom(playAtom);
const hover = useAtomValue(hoverAtom);
const common = css(
[
{
backgroundColor: (theme) => theme.darkOverlay,
marginHorizontal: ts(3),
},
],
touchOnly,
);
return (
<HoverTouch
{...css(
{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
props,
)}
>
{hover && (
<>
<IconButton
icon={SkipPrevious}
as={Link}
href={previousSlug!}
replace
size={ts(4)}
{...css(
[!previousSlug && { opacity: 0, pointerEvents: "none" }],
common,
)}
/>
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)}
size={ts(8)}
{...common}
/>
<IconButton
icon={SkipNext}
as={Link}
href={nextSlug!}
replace
size={ts(4)}
{...css(
[!nextSlug && { opacity: 0, pointerEvents: "none" }],
common,
)}
/>
</>
)}
</HoverTouch>
);
};

View File

@ -1,199 +0,0 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import "react-native-video";
import type { ReactVideoSourceProperties } from "react-native-video";
declare module "react-native-video" {
interface ReactVideoProps {
fonts?: string[];
subtitles?: Subtitle[];
onMediaUnsupported?: () => void;
}
export type VideoProps = Omit<ReactVideoProps, "source"> & {
source: ReactVideoSourceProperties & { hls: string | null };
};
}
export * from "react-native-video";
import { type Audio, type Subtitle, useToken } from "@kyoo/models";
import { type IconButton, Menu } from "@kyoo/primitives";
import "@kyoo/primitives/src/types.d.ts";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { type ComponentProps, forwardRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import uuid from "react-native-uuid";
import NativeVideo, {
type VideoRef,
type OnLoadData,
type VideoProps,
SelectedTrackType,
SelectedVideoTrackType,
} from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import { useDisplayName } from "../../../../packages/ui/src/utils";
import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./old/statee";
const MimeTypes: Map<string, string> = new Map([
["subrip", "application/x-subrip"],
["ass", "text/x-ssa"],
["vtt", "text/vtt"],
]);
const infoAtom = atom<OnLoadData | null>(null);
const videoAtom = atom(0);
const clientId = uuid.v4() as string;
const Video = forwardRef<VideoRef, VideoProps>(function Video(
{ onLoad, onBuffer, onError, onMediaUnsupported, source, subtitles, ...props },
ref,
) {
const { css } = useYoshiki();
const token = useToken();
const setInfo = useSetAtom(infoAtom);
const [video, setVideo] = useAtom(videoAtom);
const audio = useAtomValue(audioAtom);
const subtitle = useAtomValue(subtitleAtom);
const mode = useAtomValue(playModeAtom);
useEffect(() => {
if (mode === PlayMode.Hls) setVideo(-1);
}, [mode, setVideo]);
return (
<View {...css({ flexGrow: 1, flexShrink: 1 })}>
<NativeVideo
ref={ref}
source={{
...source,
headers: {
...(token ? { Authorization: token } : {}),
"X-CLIENT-ID": clientId,
},
}}
onLoad={(info) => {
onBuffer?.({ isBuffering: false });
setInfo(info);
onLoad?.(info);
}}
onBuffer={onBuffer}
onError={(error) => {
console.error(error);
if (mode === PlayMode.Direct) onMediaUnsupported?.();
else onError?.(error);
}}
selectedVideoTrack={
video === -1
? { type: SelectedVideoTrackType.AUTO }
: { type: SelectedVideoTrackType.RESOLUTION, value: video }
}
// when video file is invalid, audio is undefined
selectedAudioTrack={{ type: SelectedTrackType.INDEX, value: audio?.index ?? 0 }}
textTracks={subtitles
?.filter((x) => !!x.link)
.map((x) => ({
type: MimeTypes.get(x.codec) as any,
uri: x.link!,
title: x.title ?? "Unknown",
language: x.language ?? ("Unknown" as any),
}))}
selectedTextTrack={
subtitle
? {
type: SelectedTrackType.INDEX,
value: subtitles?.indexOf(subtitle),
}
: { type: SelectedTrackType.DISABLED, value: "" }
}
{...props}
/>
</View>
);
});
export default Video;
// mobile should be able to play everything
export const canPlay = (_codec: string) => true;
type CustomMenu = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
export const AudiosMenu = ({ audios, ...props }: CustomMenu & { audios?: Audio[] }) => {
const info = useAtomValue(infoAtom);
const [audio, setAudio] = useAtom(audioAtom);
const getDisplayName = useDisplayName();
if (!info || info.audioTracks.length < 2) return null;
return (
<Menu {...props}>
{info.audioTracks.map((x) => (
<Menu.Item
key={x.index}
label={audios ? getDisplayName(audios[x.index]) : (x.title ?? x.language ?? "Unknown")}
selected={audio!.index === x.index}
onSelect={() => setAudio(x as any)}
/>
))}
</Menu>
);
};
export const QualitiesMenu = (props: CustomMenu) => {
const { t } = useTranslation();
const info = useAtomValue(infoAtom);
const [mode, setPlayMode] = useAtom(playModeAtom);
const [video, setVideo] = useAtom(videoAtom);
return (
<Menu {...props}>
<Menu.Item
label={t("player.direct")}
selected={mode === PlayMode.Direct}
onSelect={() => setPlayMode(PlayMode.Direct)}
/>
<Menu.Item
// TODO: Display the currently selected quality (impossible with rn-video right now)
label={t("player.auto")}
selected={video === -1 && mode === PlayMode.Hls}
onSelect={() => {
setPlayMode(PlayMode.Hls);
setVideo(-1);
}}
/>
{/* TODO: Support video tracks when the play mode is not hls. */}
{info?.videoTracks
.sort((a: any, b: any) => b.height - a.height)
.map((x: any, i: number) => (
<Menu.Item
key={i}
label={`${x.height}p`}
selected={video === x.height}
onSelect={() => {
setPlayMode(PlayMode.Hls);
setVideo(x.height);
}}
/>
))}
</Menu>
);
};