Add keybindings for the player

This commit is contained in:
Zoe Roux 2022-10-10 22:24:51 +09:00
parent 6bcff8d9a2
commit 2c06924792
3 changed files with 205 additions and 10 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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, Action>(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]);
};

View File

@ -32,6 +32,7 @@ import { useRouter } from "next/router";
import Head from "next/head"; import Head from "next/head";
import { makeTitle } from "~/utils/utils"; import { makeTitle } from "~/utils/utils";
import { episodeDisplayNumber } from "~/components/episode"; 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 // 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) // 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); setPlay(true);
}, [slug, setPlay]); }, [slug, setPlay]);
useSubtitleController(playerRef, data?.subtitles, data?.fonts);
useEffect(() => { useEffect(() => {
if (!/Mobi/i.test(window.navigator.userAgent)) return; if (!/Mobi/i.test(window.navigator.userAgent)) return;
setFullscreen(true); setFullscreen(true);
return () => setFullscreen(false); return () => setFullscreen(false);
}, [setFullscreen]); }, [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 <ErrorPage {...error} />; if (error) return <ErrorPage {...error} />;
return ( return (

View File

@ -43,7 +43,8 @@ export const [_playAtom, playAtom] = bakedAtom(true, async (get, set, value) =>
try { try {
await player.current.play(); await player.current.play();
} catch (e) { } 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 if (!(e instanceof DOMException && e.name === "NotAllowedError")) console.log(e);
} }
} else { } else {
@ -63,7 +64,7 @@ export const [_volumeAtom, volumeAtom] = bakedAtom(100, (get, set, value, baker)
const player = get(playerAtom); const player = get(playerAtom);
if (!player?.current) return; if (!player?.current) return;
set(baker, value); 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) => { export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker) => {
const player = get(playerAtom); const player = get(playerAtom);
@ -75,12 +76,13 @@ export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker)
try { try {
if (value) { if (value) {
await document.body.requestFullscreen(); await document.body.requestFullscreen();
set(baker, true);
await screen.orientation.lock("landscape"); await screen.orientation.lock("landscape");
} else { } else {
await document.exitFullscreen(); await document.exitFullscreen();
set(baker, false);
screen.orientation.unlock(); screen.orientation.unlock();
} }
set(baker, value);
} catch {} } catch {}
}); });
@ -140,11 +142,7 @@ export const useVideoController = (links?: { direct: string; transmux: string })
const videoProps: BoxProps<"video"> = { const videoProps: BoxProps<"video"> = {
ref: player, ref: player,
onDoubleClick: () => { onDoubleClick: () => {
if (document.fullscreenElement) { setFullscreen(!document.fullscreenElement);
setFullscreen(false);
} else {
setFullscreen(true);
}
}, },
onPlay: () => setPlay(true), onPlay: () => setPlay(true),
onPause: () => setPlay(false), onPause: () => setPlay(false),