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 ( - - {info.audioTracks.map((x) => ( - setAudio(x as any)} - /> - ))} - - ); -}; - -export const QualitiesMenu = (props: CustomMenu) => { - const { t } = useTranslation(); - const info = useAtomValue(infoAtom); - const [mode, setPlayMode] = useAtom(playModeAtom); - const [video, setVideo] = useAtom(videoAtom); - - return ( - - setPlayMode(PlayMode.Direct)} - /> - { - setPlayMode(PlayMode.Hls); - setVideo(-1); - }} - /> - {/* TODO: Support video tracks when the play mode is not hls. */} - {info?.videoTracks - .sort((a: any, b: any) => b.height - a.height) - .map((x: any, i: number) => ( - { - setPlayMode(PlayMode.Hls); - setVideo(x.height); - }} - /> - ))} - - ); -};