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),