diff --git a/front/src/player/keyboard.tsx b/front/src/player/keyboard.tsx
new file mode 100644
index 00000000..3ef0615b
--- /dev/null
+++ b/front/src/player/keyboard.tsx
@@ -0,0 +1,186 @@
+/*
+ * 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 .
+ */
+
+import { atom, useSetAtom } from "jotai";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
+import { Font, Track } from "~/models/resources/watch-item";
+import {
+ durationAtom,
+ fullscreenAtom,
+ mutedAtom,
+ playAtom,
+ progressAtom,
+ subtitleAtom,
+ volumeAtom,
+} from "./state";
+
+type Action =
+ | { type: "play" }
+ | { type: "mute" }
+ | { type: "fullscreen" }
+ | { type: "seek"; value: number }
+ | { type: "seekPercent"; value: number }
+ | { type: "volume"; value: number }
+ | { type: "subtitle"; subtitles: Track[]; fonts: Font[] };
+
+const keyboardReducerAtom = atom(null, (get, set, action) => {
+ 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":
+ set(progressAtom, get(progressAtom) + action.value);
+ break;
+ case "seekPercent":
+ set(progressAtom, (get(durationAtom) * action.value) / 100);
+ break;
+ case "volume":
+ set(volumeAtom, get(volumeAtom) + action.value);
+ break;
+ case "subtitle":
+ const subtitle = get(subtitleAtom);
+ const index = subtitle ? action.subtitles.findIndex((x) => x.id === subtitle.id) : -1;
+ set(
+ subtitleAtom,
+ index === -1
+ ? null
+ : {
+ track: action.subtitles[(index + 1) % action.subtitles.length],
+ fonts: action.fonts,
+ },
+ );
+ break;
+ }
+});
+
+export const useVideoKeyboard = (
+ subtitles?: Track[],
+ fonts?: Font[],
+ previousEpisode?: string,
+ nextEpisode?: string,
+) => {
+ const reducer = useSetAtom(keyboardReducerAtom);
+ const router = useRouter();
+
+ useEffect(() => {
+ 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 "0":
+ reducer({ type: "seekPercent", value: 0 });
+ break;
+ case "1":
+ reducer({ type: "seekPercent", value: 10 });
+ break;
+ case "2":
+ reducer({ type: "seekPercent", value: 20 });
+ break;
+ case "3":
+ reducer({ type: "seekPercent", value: 30 });
+ break;
+ case "4":
+ reducer({ type: "seekPercent", value: 40 });
+ break;
+ case "5":
+ reducer({ type: "seekPercent", value: 50 });
+ break;
+ case "6":
+ reducer({ type: "seekPercent", value: 60 });
+ break;
+ case "7":
+ reducer({ type: "seekPercent", value: 70 });
+ break;
+ case "8":
+ reducer({ type: "seekPercent", value: 80 });
+ break;
+ case "9":
+ reducer({ type: "seekPercent", value: 90 });
+ 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;
+ }
+ };
+
+ document.addEventListener("keyup", handler);
+ return () => document.removeEventListener("keyup", handler);
+ }, [subtitles, fonts, nextEpisode, previousEpisode, router, reducer]);
+};
diff --git a/front/src/player/player.tsx b/front/src/player/player.tsx
index 5c906a89..be7d6f5d 100644
--- a/front/src/player/player.tsx
+++ b/front/src/player/player.tsx
@@ -32,6 +32,7 @@ import { useRouter } from "next/router";
import Head from "next/head";
import { makeTitle } from "~/utils/utils";
import { episodeDisplayNumber } from "~/components/episode";
+import { useVideoKeyboard } from "./keyboard";
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
@@ -76,14 +77,24 @@ const Player: QueryPage<{ slug: string }> = ({ slug }) => {
setPlay(true);
}, [slug, setPlay]);
- useSubtitleController(playerRef, data?.subtitles, data?.fonts);
-
useEffect(() => {
if (!/Mobi/i.test(window.navigator.userAgent)) return;
setFullscreen(true);
return () => setFullscreen(false);
}, [setFullscreen]);
+ useSubtitleController(playerRef, data?.subtitles, data?.fonts);
+ useVideoKeyboard(
+ data?.subtitles,
+ data?.fonts,
+ data && !data.isMovie && data.previousEpisode
+ ? `/watch/${data.previousEpisode.slug}`
+ : undefined,
+ data && !data.isMovie && data.nextEpisode
+ ? `/watch/${data.nextEpisode.slug}`
+ : undefined,
+ );
+
if (error) return ;
return (
diff --git a/front/src/player/state.tsx b/front/src/player/state.tsx
index 78b4759f..2d0a0b43 100644
--- a/front/src/player/state.tsx
+++ b/front/src/player/state.tsx
@@ -43,7 +43,8 @@ export const [_playAtom, playAtom] = bakedAtom(true, async (get, set, value) =>
try {
await player.current.play();
} catch (e) {
- if (e instanceof DOMException && e.name === "NotSupportedError") set(playModeAtom, PlayMode.Transmux);
+ if (e instanceof DOMException && e.name === "NotSupportedError")
+ set(playModeAtom, PlayMode.Transmux);
else if (!(e instanceof DOMException && e.name === "NotAllowedError")) console.log(e);
}
} else {
@@ -63,7 +64,7 @@ export const [_volumeAtom, volumeAtom] = bakedAtom(100, (get, set, value, baker)
const player = get(playerAtom);
if (!player?.current) return;
set(baker, value);
- if (player.current) player.current.volume = value / 100;
+ if (player.current) player.current.volume = Math.max(0, Math.min(value, 100)) / 100;
});
export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker) => {
const player = get(playerAtom);
@@ -75,12 +76,13 @@ export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker)
try {
if (value) {
await document.body.requestFullscreen();
+ set(baker, true);
await screen.orientation.lock("landscape");
} else {
await document.exitFullscreen();
+ set(baker, false);
screen.orientation.unlock();
}
- set(baker, value);
} catch {}
});
@@ -140,11 +142,7 @@ export const useVideoController = (links?: { direct: string; transmux: string })
const videoProps: BoxProps<"video"> = {
ref: player,
onDoubleClick: () => {
- if (document.fullscreenElement) {
- setFullscreen(false);
- } else {
- setFullscreen(true);
- }
+ setFullscreen(!document.fullscreenElement);
},
onPlay: () => setPlay(true),
onPause: () => setPlay(false),