diff --git a/front/src/ui/player/index.tsx b/front/src/ui/player/index.tsx
index 489f05a9..b1c11b9d 100644
--- a/front/src/ui/player/index.tsx
+++ b/front/src/ui/player/index.tsx
@@ -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;
diff --git a/front/src/ui/player/keyboard.tsx b/front/src/ui/player/keyboard.tsx
new file mode 100644
index 00000000..4cd8891c
--- /dev/null
+++ b/front/src/ui/player/keyboard.tsx
@@ -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]);
+};
diff --git a/front/src/ui/player/old/keyboard.tsx b/front/src/ui/player/old/keyboard.tsx
deleted file mode 100644
index 13ba652e..00000000
--- a/front/src/ui/player/old/keyboard.tsx
+++ /dev/null
@@ -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 .
- */
-
-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]);
-};
diff --git a/front/src/ui/player/old/left-buttons.tsx b/front/src/ui/player/old/left-buttons.tsx
deleted file mode 100644
index 51bb9daf..00000000
--- a/front/src/ui/player/old/left-buttons.tsx
+++ /dev/null
@@ -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 (
-
- {hover && (
- <>
-
- setPlay(!isPlaying)}
- size={ts(8)}
- {...common}
- />
-
- >
- )}
-
- );
-};
diff --git a/front/src/ui/player/old/video.tsx b/front/src/ui/player/old/video.tsx
deleted file mode 100644
index c032bf1f..00000000
--- a/front/src/ui/player/old/video.tsx
+++ /dev/null
@@ -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 .
- */
-
-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 & {
- 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 = new Map([
- ["subrip", "application/x-subrip"],
- ["ass", "text/x-ssa"],
- ["vtt", "text/vtt"],
-]);
-
-const infoAtom = atom(null);
-const videoAtom = atom(0);
-
-const clientId = uuid.v4() as string;
-
-const Video = forwardRef(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 (
-
- {
- 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}
- />
-
- );
-});
-
-export default Video;
-
-// mobile should be able to play everything
-export const canPlay = (_codec: string) => true;
-
-type CustomMenu = ComponentProps>>;
-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 (
-
- );
-};
-
-export const QualitiesMenu = (props: CustomMenu) => {
- const { t } = useTranslation();
- const info = useAtomValue(infoAtom);
- const [mode, setPlayMode] = useAtom(playModeAtom);
- const [video, setVideo] = useAtom(videoAtom);
-
- return (
-
- );
-};