mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Add keybindings for the player
This commit is contained in:
parent
6bcff8d9a2
commit
2c06924792
186
front/src/player/keyboard.tsx
Normal file
186
front/src/player/keyboard.tsx
Normal 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]);
|
||||||
|
};
|
@ -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 (
|
||||||
|
@ -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),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user