mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-10-31 18:47:11 -04:00
Rewrite player's controls compenents
This commit is contained in:
parent
a310ceaed5
commit
fc9695a2dc
@ -66,8 +66,12 @@ export const expo: ExpoConfig = {
|
|||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
enableNotificationControls: true,
|
|
||||||
enableAndroidPictureInPicture: true,
|
enableAndroidPictureInPicture: true,
|
||||||
|
enableBackgroundAudio: true,
|
||||||
|
androidExtensions: {
|
||||||
|
useExoplayerDash: true,
|
||||||
|
useExoplayerHls: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,482 +0,0 @@
|
|||||||
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
|
|
||||||
import {
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
type ImageStyle,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { useEvent, type VideoPlayer } from "react-native-video";
|
|
||||||
import { percent, rem, useYoshiki } from "yoshiki/native";
|
|
||||||
import type { AudioTrack, Chapter, KImage, Subtitle } from "~/models";
|
|
||||||
import {
|
|
||||||
alpha,
|
|
||||||
CircularProgress,
|
|
||||||
ContrastArea,
|
|
||||||
H1,
|
|
||||||
H2,
|
|
||||||
IconButton,
|
|
||||||
Poster,
|
|
||||||
PressableFeedback,
|
|
||||||
Skeleton,
|
|
||||||
Slider,
|
|
||||||
Tooltip,
|
|
||||||
tooltip,
|
|
||||||
ts,
|
|
||||||
useIsTouch,
|
|
||||||
} from "~/primitives";
|
|
||||||
import { LeftButtons, TouchControls } from "./left-buttons";
|
|
||||||
import { RightButtons } from "./right-buttons";
|
|
||||||
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
|
|
||||||
|
|
||||||
export const Hover = ({
|
|
||||||
isLoading,
|
|
||||||
url,
|
|
||||||
name,
|
|
||||||
showName,
|
|
||||||
poster,
|
|
||||||
chapters,
|
|
||||||
subtitles,
|
|
||||||
audios,
|
|
||||||
fonts,
|
|
||||||
previousSlug,
|
|
||||||
nextSlug,
|
|
||||||
}: {
|
|
||||||
isLoading: boolean;
|
|
||||||
url: string;
|
|
||||||
name?: string | null;
|
|
||||||
showName?: string;
|
|
||||||
poster?: KyooImage | null;
|
|
||||||
chapters?: Chapter[];
|
|
||||||
subtitles?: Subtitle[];
|
|
||||||
audios?: Audio[];
|
|
||||||
fonts?: string[];
|
|
||||||
previousSlug?: string | null;
|
|
||||||
nextSlug?: string | null;
|
|
||||||
}) => {
|
|
||||||
const show = useAtomValue(hoverAtom);
|
|
||||||
const setHover = useSetAtom(hoverReasonAtom);
|
|
||||||
const isSeeking = useAtomValue(seekingAtom);
|
|
||||||
const isTouch = useIsTouch();
|
|
||||||
|
|
||||||
const showBottomSeeker = isSeeking && isTouch;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContrastArea mode="dark">
|
|
||||||
{({ css }) => (
|
|
||||||
<>
|
|
||||||
<TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
|
|
||||||
<View
|
|
||||||
onPointerEnter={(e) => {
|
|
||||||
if (e.nativeEvent.pointerType === "mouse")
|
|
||||||
setHover((x) => ({ ...x, mouseHover: true }));
|
|
||||||
}}
|
|
||||||
onPointerLeave={(e) => {
|
|
||||||
if (e.nativeEvent.pointerType === "mouse")
|
|
||||||
setHover((x) => ({ ...x, mouseHover: false }));
|
|
||||||
}}
|
|
||||||
{...css({
|
|
||||||
// TODO: animate show
|
|
||||||
display: !show ? "none" : "flex",
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
// box-none does not work on the web while none does not work on android
|
|
||||||
pointerEvents: Platform.OS === "web" ? "none" : "box-none",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Back
|
|
||||||
isLoading={isLoading}
|
|
||||||
name={showName}
|
|
||||||
{...css({
|
|
||||||
pointerEvents: "auto",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
// Fixed is used because firefox android make the hover disapear under the navigation bar in absolute
|
|
||||||
position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bg: (theme) => theme.darkOverlay,
|
|
||||||
flexDirection: "row",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
padding: percent(1),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<VideoPoster
|
|
||||||
poster={poster}
|
|
||||||
alt={showName}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
marginLeft: { xs: ts(0.5), sm: ts(3) },
|
|
||||||
flexDirection: "column",
|
|
||||||
flexGrow: 1,
|
|
||||||
flexShrink: 1,
|
|
||||||
maxWidth: percent(100),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{!showBottomSeeker && (
|
|
||||||
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
|
|
||||||
{isLoading ? (
|
|
||||||
<Skeleton {...css({ width: rem(15), height: rem(2) })} />
|
|
||||||
) : (
|
|
||||||
name
|
|
||||||
)}
|
|
||||||
</H2>
|
|
||||||
)}
|
|
||||||
<ProgressBar chapters={chapters} url={url} />
|
|
||||||
{showBottomSeeker ? (
|
|
||||||
<BottomScrubber url={url} chapters={chapters} />
|
|
||||||
) : (
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
flexDirection: "row",
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<LeftButtons
|
|
||||||
previousSlug={previousSlug}
|
|
||||||
nextSlug={nextSlug}
|
|
||||||
/>
|
|
||||||
<RightButtons
|
|
||||||
subtitles={subtitles}
|
|
||||||
audios={audios}
|
|
||||||
fonts={fonts}
|
|
||||||
onMenuOpen={() =>
|
|
||||||
setHover((x) => ({ ...x, menuOpened: true }))
|
|
||||||
}
|
|
||||||
onMenuClose={() => {
|
|
||||||
// Disable hover since the menu overlay makes the mouseout unreliable.
|
|
||||||
setHover((x) => ({
|
|
||||||
...x,
|
|
||||||
menuOpened: false,
|
|
||||||
mouseHover: false,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ContrastArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
|
|
||||||
const hover = useAtomValue(hoverAtom);
|
|
||||||
const setHover = useSetAtom(hoverReasonAtom);
|
|
||||||
const mouseCallback = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({
|
|
||||||
count: 0,
|
|
||||||
});
|
|
||||||
const playerWidth = useRef<number | null>(null);
|
|
||||||
const isTouch = useIsTouch();
|
|
||||||
|
|
||||||
const show = useCallback(() => {
|
|
||||||
setHover((x) => ({ ...x, mouseMoved: true }));
|
|
||||||
if (mouseCallback.current) clearTimeout(mouseCallback.current);
|
|
||||||
mouseCallback.current = setTimeout(() => {
|
|
||||||
setHover((x) => ({ ...x, mouseMoved: false }));
|
|
||||||
}, 2500);
|
|
||||||
}, [setHover]);
|
|
||||||
|
|
||||||
// On mouse move
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS !== "web") return;
|
|
||||||
const handler = (e: PointerEvent) => {
|
|
||||||
if (e.pointerType !== "mouse") return;
|
|
||||||
show();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("pointermove", handler);
|
|
||||||
return () => document.removeEventListener("pointermove", handler);
|
|
||||||
}, [show]);
|
|
||||||
|
|
||||||
// When the controls hide, remove focus so space can be used to play/pause instead of triggering the button
|
|
||||||
// It also serves to hide the tooltip.
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS !== "web") return;
|
|
||||||
if (!hover && document.activeElement instanceof HTMLElement)
|
|
||||||
document.activeElement.blur();
|
|
||||||
}, [hover]);
|
|
||||||
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
|
|
||||||
const duration = useAtomValue(durationAtom);
|
|
||||||
const setPlay = useSetAtom(playAtom);
|
|
||||||
const setProgress = useSetAtom(progressAtom);
|
|
||||||
const setFullscreen = useSetAtom(fullscreenAtom);
|
|
||||||
|
|
||||||
const onPress = (e: { pointerType: string; x: number }) => {
|
|
||||||
if (Platform.OS === "web" && e.pointerType === "mouse") {
|
|
||||||
setPlay((x) => !x);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hover) setHover((x) => ({ ...x, mouseMoved: false }));
|
|
||||||
else show();
|
|
||||||
};
|
|
||||||
const onDoublePress = (e: { pointerType: string; x: number }) => {
|
|
||||||
if (Platform.OS === "web" && e.pointerType === "mouse") {
|
|
||||||
// Only reset touch count for the web, on mobile you can continue to seek by pressing again.
|
|
||||||
touch.current.count = 0;
|
|
||||||
setFullscreen((x) => !x);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
show();
|
|
||||||
if (!duration || !playerWidth.current) return;
|
|
||||||
|
|
||||||
if (e.x < playerWidth.current * 0.33) {
|
|
||||||
setProgress((x) => Math.max(x - 10, 0));
|
|
||||||
}
|
|
||||||
if (e.x > playerWidth.current * 0.66) {
|
|
||||||
setProgress((x) => Math.min(x + 10, duration));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAnyPress = (e: { pointerType: string; x: number }) => {
|
|
||||||
touch.current.count++;
|
|
||||||
if (touch.current.count >= 2) {
|
|
||||||
onDoublePress(e);
|
|
||||||
clearTimeout(touch.current.timeout);
|
|
||||||
} else {
|
|
||||||
onPress(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
touch.current.timeout = setTimeout(() => {
|
|
||||||
touch.current.count = 0;
|
|
||||||
touch.current.timeout = undefined;
|
|
||||||
}, 400);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
tabIndex={-1}
|
|
||||||
onPointerLeave={(e) => {
|
|
||||||
if (e.nativeEvent.pointerType === "mouse")
|
|
||||||
setHover((x) => ({ ...x, mouseMoved: false }));
|
|
||||||
}}
|
|
||||||
onPress={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onAnyPress({
|
|
||||||
pointerType: isTouch ? "touch" : "mouse",
|
|
||||||
x: e.nativeEvent.locationX ?? e.nativeEvent.pageX,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onLayout={(e) => {
|
|
||||||
playerWidth.current = e.nativeEvent.layout.width;
|
|
||||||
}}
|
|
||||||
{...css(
|
|
||||||
// @ts-expect-error Web only property (cursor: unset)
|
|
||||||
{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
cursor: hover ? "unset" : "none",
|
|
||||||
},
|
|
||||||
props,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProgressBar = ({
|
|
||||||
url,
|
|
||||||
chapters,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
chapters?: Chapter[];
|
|
||||||
}) => {
|
|
||||||
const [progress, setProgress] = useAtom(progressAtom);
|
|
||||||
const buffered = useAtomValue(bufferedAtom);
|
|
||||||
const duration = useAtomValue(durationAtom);
|
|
||||||
const setPlay = useSetAtom(playAtom);
|
|
||||||
const [hoverProgress, setHoverProgress] = useState<number | null>(null);
|
|
||||||
const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
|
||||||
const [seekProgress, setSeekProgress] = useAtom(seekProgressAtom);
|
|
||||||
const setSeeking = useSetAtom(seekingAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
progress={seekProgress ?? progress}
|
|
||||||
startSeek={() => {
|
|
||||||
setPlay(false);
|
|
||||||
setSeeking(true);
|
|
||||||
}}
|
|
||||||
endSeek={() => {
|
|
||||||
setSeeking(false);
|
|
||||||
setProgress(seekProgress!);
|
|
||||||
setSeekProgress(null);
|
|
||||||
setTimeout(() => setPlay(true), 10);
|
|
||||||
}}
|
|
||||||
onHover={(progress, layout) => {
|
|
||||||
setHoverProgress(progress);
|
|
||||||
setLayout(layout);
|
|
||||||
}}
|
|
||||||
setProgress={(progress) => setSeekProgress(progress)}
|
|
||||||
subtleProgress={buffered}
|
|
||||||
max={duration}
|
|
||||||
markers={chapters?.map((x) => x.startTime)}
|
|
||||||
dataSet={{ tooltipId: "progress-scrubber" }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
id={"progress-scrubber"}
|
|
||||||
isOpen={hoverProgress !== null}
|
|
||||||
place="top"
|
|
||||||
position={{
|
|
||||||
x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1),
|
|
||||||
y: layout.y,
|
|
||||||
}}
|
|
||||||
render={() =>
|
|
||||||
hoverProgress ? (
|
|
||||||
<ScrubberTooltip
|
|
||||||
seconds={hoverProgress}
|
|
||||||
chapters={chapters}
|
|
||||||
url={url}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
opacity={1}
|
|
||||||
style={{ padding: 0, borderRadius: imageBorderRadius }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Back = ({
|
|
||||||
isLoading,
|
|
||||||
name,
|
|
||||||
...props
|
|
||||||
}: { isLoading: boolean; name?: string } & ViewProps) => {
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
{...css(
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bg: (theme) => theme.darkOverlay,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: percent(0.33),
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
props,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon={ArrowBack}
|
|
||||||
as={PressableFeedback}
|
|
||||||
onPress={router.back}
|
|
||||||
{...tooltip(t("player.back"))}
|
|
||||||
/>
|
|
||||||
<Skeleton>
|
|
||||||
{isLoading ? (
|
|
||||||
<Skeleton {...css({ width: rem(5) })} />
|
|
||||||
) : (
|
|
||||||
<H1
|
|
||||||
{...css({
|
|
||||||
alignSelf: "center",
|
|
||||||
fontSize: rem(1.5),
|
|
||||||
marginLeft: rem(1),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</H1>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VideoPoster = ({
|
|
||||||
poster,
|
|
||||||
alt,
|
|
||||||
isLoading,
|
|
||||||
}: {
|
|
||||||
poster?: KyooImage | null;
|
|
||||||
alt?: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
}) => {
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
width: "15%",
|
|
||||||
display: { xs: "none", sm: "flex" },
|
|
||||||
position: "relative",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Poster
|
|
||||||
src={poster}
|
|
||||||
quality="low"
|
|
||||||
alt={alt}
|
|
||||||
forcedLoading={isLoading}
|
|
||||||
layout={{ width: percent(100) }}
|
|
||||||
{...(css({ position: "absolute", bottom: 0 }) as { style: ImageStyle })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => {
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
const [isLoading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
useEvent(player, "onStatusChange", (status) => {
|
|
||||||
setLoading(status === "loading");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isLoading) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
position: "absolute",
|
|
||||||
pointerEvents: "none",
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bg: (theme) => alpha(theme.colors.black, 0.3),
|
|
||||||
justifyContent: "center",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<CircularProgress {...css({ alignSelf: "center" })} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,221 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { IconButton, Link, P, Slider, noTouch, tooltip, touchOnly, ts } from "@kyoo/primitives";
|
|
||||||
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
|
|
||||||
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
|
||||||
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
|
|
||||||
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
|
|
||||||
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
|
|
||||||
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
|
|
||||||
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
|
|
||||||
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { type Stylable, px, useYoshiki } from "yoshiki/native";
|
|
||||||
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
|
|
||||||
import { HoverTouch, hoverAtom } from "./hover";
|
|
||||||
|
|
||||||
export const LeftButtons = ({
|
|
||||||
previousSlug,
|
|
||||||
nextSlug,
|
|
||||||
}: {
|
|
||||||
previousSlug?: string | null;
|
|
||||||
nextSlug?: string | null;
|
|
||||||
}) => {
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
|
||||||
|
|
||||||
const spacing = css({ marginHorizontal: ts(1) });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...css({ flexDirection: "row" })}>
|
|
||||||
<View {...css({ flexDirection: "row" }, noTouch)}>
|
|
||||||
{previousSlug && (
|
|
||||||
<IconButton
|
|
||||||
icon={SkipPrevious}
|
|
||||||
as={Link}
|
|
||||||
href={previousSlug}
|
|
||||||
replace
|
|
||||||
{...tooltip(t("player.previous"), true)}
|
|
||||||
{...spacing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
icon={isPlaying ? Pause : PlayArrow}
|
|
||||||
onPress={() => setPlay(!isPlaying)}
|
|
||||||
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
|
|
||||||
{...spacing}
|
|
||||||
/>
|
|
||||||
{nextSlug && (
|
|
||||||
<IconButton
|
|
||||||
icon={SkipNext}
|
|
||||||
as={Link}
|
|
||||||
href={nextSlug}
|
|
||||||
replace
|
|
||||||
{...tooltip(t("player.next"), true)}
|
|
||||||
{...spacing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{Platform.OS === "web" && <VolumeSlider />}
|
|
||||||
</View>
|
|
||||||
<ProgressText {...css({ marginLeft: ts(1) })} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<HoverTouch
|
|
||||||
{...css(
|
|
||||||
{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
props,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hover && (
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
icon={SkipPrevious}
|
|
||||||
as={Link}
|
|
||||||
href={previousSlug!}
|
|
||||||
replace
|
|
||||||
size={ts(4)}
|
|
||||||
{...css([!previousSlug && { opacity: 0, pointerEvents: "none" }], common)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={isPlaying ? Pause : PlayArrow}
|
|
||||||
onPress={() => setPlay(!isPlaying)}
|
|
||||||
size={ts(8)}
|
|
||||||
{...common}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={SkipNext}
|
|
||||||
as={Link}
|
|
||||||
href={nextSlug!}
|
|
||||||
replace
|
|
||||||
size={ts(4)}
|
|
||||||
{...css([!nextSlug && { opacity: 0, pointerEvents: "none" }], common)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HoverTouch>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VolumeSlider = () => {
|
|
||||||
const [volume, setVolume] = useAtom(volumeAtom);
|
|
||||||
const [isMuted, setMuted] = useAtom(mutedAtom);
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
{...css({
|
|
||||||
display: { xs: "none", sm: "flex" },
|
|
||||||
alignItems: "center",
|
|
||||||
flexDirection: "row",
|
|
||||||
paddingRight: ts(1),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon={
|
|
||||||
isMuted || volume === 0
|
|
||||||
? VolumeOff
|
|
||||||
: volume < 25
|
|
||||||
? VolumeMute
|
|
||||||
: volume < 65
|
|
||||||
? VolumeDown
|
|
||||||
: VolumeUp
|
|
||||||
}
|
|
||||||
onPress={() => setMuted(!isMuted)}
|
|
||||||
{...tooltip(t("player.mute"), true)}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
progress={volume}
|
|
||||||
setProgress={setVolume}
|
|
||||||
size={4}
|
|
||||||
{...css({ width: px(100) })}
|
|
||||||
{...tooltip(t("player.volume"), true)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProgressText = (props: Stylable) => {
|
|
||||||
const progress = useAtomValue(progressAtom);
|
|
||||||
const duration = useAtomValue(durationAtom);
|
|
||||||
const { css } = useYoshiki();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<P {...css({ alignSelf: "center" }, props)}>
|
|
||||||
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
|
||||||
</P>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const toTimerString = (timer?: number, duration?: number) => {
|
|
||||||
if (!duration) duration = timer;
|
|
||||||
if (
|
|
||||||
timer === undefined ||
|
|
||||||
duration === undefined ||
|
|
||||||
Number.isNaN(duration) ||
|
|
||||||
Number.isNaN(timer)
|
|
||||||
)
|
|
||||||
return "??:??";
|
|
||||||
const h = Math.floor(timer / 3600);
|
|
||||||
const min = Math.floor((timer / 60) % 60);
|
|
||||||
const sec = Math.floor(timer % 60);
|
|
||||||
const fmt = (n: number) => n.toString().padStart(2, "0");
|
|
||||||
|
|
||||||
if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`;
|
|
||||||
return `${fmt(min)}:${fmt(sec)}`;
|
|
||||||
};
|
|
||||||
88
front/src/ui/player/controls/back.tsx
Normal file
88
front/src/ui/player/controls/back.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { percent, rem, useYoshiki } from "yoshiki/native";
|
||||||
|
import {
|
||||||
|
H1,
|
||||||
|
IconButton,
|
||||||
|
PressableFeedback,
|
||||||
|
Skeleton,
|
||||||
|
tooltip,
|
||||||
|
} from "~/primitives";
|
||||||
|
|
||||||
|
export const Back = ({ name, ...props }: { name: string } & ViewProps) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bg: (theme) => theme.darkOverlay,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: percent(0.33),
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={ArrowBack}
|
||||||
|
as={PressableFeedback}
|
||||||
|
onPress={router.back}
|
||||||
|
{...tooltip(t("player.back"))}
|
||||||
|
/>
|
||||||
|
<H1
|
||||||
|
{...css({
|
||||||
|
alignSelf: "center",
|
||||||
|
fontSize: rem(1.5),
|
||||||
|
marginLeft: rem(1),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</H1>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Back.Loader = (props: ViewProps) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bg: (theme) => theme.darkOverlay,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: percent(0.33),
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={ArrowBack}
|
||||||
|
as={PressableFeedback}
|
||||||
|
onPress={router.back}
|
||||||
|
{...tooltip(t("player.back"))}
|
||||||
|
/>
|
||||||
|
<Skeleton {...css({ width: rem(5) })} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
307
front/src/ui/player/controls/index.tsx
Normal file
307
front/src/ui/player/controls/index.tsx
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
type ImageStyle,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||||
|
import { percent, rem, useYoshiki } from "yoshiki/native";
|
||||||
|
import type { AudioTrack, Chapter, KImage, Subtitle } from "~/models";
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
CircularProgress,
|
||||||
|
H1,
|
||||||
|
H2,
|
||||||
|
IconButton,
|
||||||
|
Poster,
|
||||||
|
PressableFeedback,
|
||||||
|
Skeleton,
|
||||||
|
Slider,
|
||||||
|
Tooltip,
|
||||||
|
tooltip,
|
||||||
|
ts,
|
||||||
|
useIsTouch,
|
||||||
|
} from "~/primitives";
|
||||||
|
import { LeftButtons } from "./components/left-buttons";
|
||||||
|
import { RightButtons } from "./components/right-buttons";
|
||||||
|
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
|
||||||
|
|
||||||
|
export const Controls = ({
|
||||||
|
player,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
player: VideoPlayer;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
poster: KImage | null;
|
||||||
|
}) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
// const show = useAtomValue(hoverAtom);
|
||||||
|
// const setHover = useSetAtom(hoverReasonAtom);
|
||||||
|
// const isSeeking = useAtomValue(seekingAtom);
|
||||||
|
// const isTouch = useIsTouch();
|
||||||
|
|
||||||
|
// const showBottomSeeker = isSeeking && isTouch;
|
||||||
|
|
||||||
|
// <TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
// onPointerEnter={(e) => {
|
||||||
|
// if (e.nativeEvent.pointerType === "mouse")
|
||||||
|
// setHover((x) => ({ ...x, mouseHover: true }));
|
||||||
|
// }}
|
||||||
|
// onPointerLeave={(e) => {
|
||||||
|
// if (e.nativeEvent.pointerType === "mouse")
|
||||||
|
// setHover((x) => ({ ...x, mouseHover: false }));
|
||||||
|
// }}
|
||||||
|
{...css({
|
||||||
|
// TODO: animate show
|
||||||
|
//display: !show ? "none" : "flex",
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
// box-none does not work on the web while none does not work on android
|
||||||
|
pointerEvents: Platform.OS === "web" ? "none" : "box-none",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Back
|
||||||
|
name={title}
|
||||||
|
{...css({
|
||||||
|
pointerEvents: "auto",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
// Fixed is used because firefox android make the hover disapear under the navigation bar in absolute
|
||||||
|
position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bg: (theme) => theme.darkOverlay,
|
||||||
|
flexDirection: "row",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
padding: percent(1),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} />
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
marginLeft: { xs: ts(0.5), sm: ts(3) },
|
||||||
|
flexDirection: "column",
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
maxWidth: percent(100),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{!showBottomSeeker && (
|
||||||
|
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton {...css({ width: rem(15), height: rem(2) })} />
|
||||||
|
) : (
|
||||||
|
name
|
||||||
|
)}
|
||||||
|
</H2>
|
||||||
|
)}
|
||||||
|
<ProgressBar chapters={chapters} url={url} />
|
||||||
|
{showBottomSeeker ? (
|
||||||
|
<BottomScrubber url={url} chapters={chapters} />
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexDirection: "row",
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
|
||||||
|
<RightButtons
|
||||||
|
subtitles={subtitles}
|
||||||
|
audios={audios}
|
||||||
|
fonts={fonts}
|
||||||
|
onMenuOpen={() => setHover((x) => ({ ...x, menuOpened: true }))}
|
||||||
|
onMenuClose={() => {
|
||||||
|
// Disable hover since the menu overlay makes the mouseout unreliable.
|
||||||
|
setHover((x) => ({
|
||||||
|
...x,
|
||||||
|
menuOpened: false,
|
||||||
|
mouseHover: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => {
|
||||||
|
const hover = useAtomValue(hoverAtom);
|
||||||
|
const setHover = useSetAtom(hoverReasonAtom);
|
||||||
|
const mouseCallback = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
const playerWidth = useRef<number | null>(null);
|
||||||
|
const isTouch = useIsTouch();
|
||||||
|
|
||||||
|
const show = useCallback(() => {
|
||||||
|
setHover((x) => ({ ...x, mouseMoved: true }));
|
||||||
|
if (mouseCallback.current) clearTimeout(mouseCallback.current);
|
||||||
|
mouseCallback.current = setTimeout(() => {
|
||||||
|
setHover((x) => ({ ...x, mouseMoved: false }));
|
||||||
|
}, 2500);
|
||||||
|
}, [setHover]);
|
||||||
|
|
||||||
|
// On mouse move
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "web") return;
|
||||||
|
const handler = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType !== "mouse") return;
|
||||||
|
show();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointermove", handler);
|
||||||
|
return () => document.removeEventListener("pointermove", handler);
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
// When the controls hide, remove focus so space can be used to play/pause instead of triggering the button
|
||||||
|
// It also serves to hide the tooltip.
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "web") return;
|
||||||
|
if (!hover && document.activeElement instanceof HTMLElement)
|
||||||
|
document.activeElement.blur();
|
||||||
|
}, [hover]);
|
||||||
|
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
const duration = useAtomValue(durationAtom);
|
||||||
|
const setPlay = useSetAtom(playAtom);
|
||||||
|
const setProgress = useSetAtom(progressAtom);
|
||||||
|
const setFullscreen = useSetAtom(fullscreenAtom);
|
||||||
|
|
||||||
|
const onPress = (e: { pointerType: string; x: number }) => {
|
||||||
|
if (Platform.OS === "web" && e.pointerType === "mouse") {
|
||||||
|
setPlay((x) => !x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hover) setHover((x) => ({ ...x, mouseMoved: false }));
|
||||||
|
else show();
|
||||||
|
};
|
||||||
|
const onDoublePress = (e: { pointerType: string; x: number }) => {
|
||||||
|
if (Platform.OS === "web" && e.pointerType === "mouse") {
|
||||||
|
// Only reset touch count for the web, on mobile you can continue to seek by pressing again.
|
||||||
|
touch.current.count = 0;
|
||||||
|
setFullscreen((x) => !x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
show();
|
||||||
|
if (!duration || !playerWidth.current) return;
|
||||||
|
|
||||||
|
if (e.x < playerWidth.current * 0.33) {
|
||||||
|
setProgress((x) => Math.max(x - 10, 0));
|
||||||
|
}
|
||||||
|
if (e.x > playerWidth.current * 0.66) {
|
||||||
|
setProgress((x) => Math.min(x + 10, duration));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAnyPress = (e: { pointerType: string; x: number }) => {
|
||||||
|
touch.current.count++;
|
||||||
|
if (touch.current.count >= 2) {
|
||||||
|
onDoublePress(e);
|
||||||
|
clearTimeout(touch.current.timeout);
|
||||||
|
} else {
|
||||||
|
onPress(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
touch.current.timeout = setTimeout(() => {
|
||||||
|
touch.current.count = 0;
|
||||||
|
touch.current.timeout = undefined;
|
||||||
|
}, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
tabIndex={-1}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
if (e.nativeEvent.pointerType === "mouse")
|
||||||
|
setHover((x) => ({ ...x, mouseMoved: false }));
|
||||||
|
}}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onAnyPress({
|
||||||
|
pointerType: isTouch ? "touch" : "mouse",
|
||||||
|
x: e.nativeEvent.locationX ?? e.nativeEvent.pageX,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onLayout={(e) => {
|
||||||
|
playerWidth.current = e.nativeEvent.layout.width;
|
||||||
|
}}
|
||||||
|
{...css(
|
||||||
|
// @ts-expect-error Web only property (cursor: unset)
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
cursor: hover ? "unset" : "none",
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoPoster = ({
|
||||||
|
poster,
|
||||||
|
alt,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
poster?: KyooImage | null;
|
||||||
|
alt?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
width: "15%",
|
||||||
|
display: { xs: "none", sm: "flex" },
|
||||||
|
position: "relative",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Poster
|
||||||
|
src={poster}
|
||||||
|
quality="low"
|
||||||
|
alt={alt}
|
||||||
|
forcedLoading={isLoading}
|
||||||
|
layout={{ width: percent(100) }}
|
||||||
|
{...(css({ position: "absolute", bottom: 0 }) as { style: ImageStyle })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
front/src/ui/player/controls/misc.tsx
Normal file
118
front/src/ui/player/controls/misc.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
|
||||||
|
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||||
|
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
|
||||||
|
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
|
||||||
|
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
|
||||||
|
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||||
|
import { px, useYoshiki } from "yoshiki/native";
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Slider,
|
||||||
|
tooltip,
|
||||||
|
ts,
|
||||||
|
} from "~/primitives";
|
||||||
|
|
||||||
|
export const PlayButton = ({ player, ...props }: { player: VideoPlayer }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [playing, setPlay] = useState(player.isPlaying);
|
||||||
|
useEvent(player, "onPlaybackStateChange", (status) => {
|
||||||
|
setPlay(status.isPlaying);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
icon={playing ? Pause : PlayArrow}
|
||||||
|
onPress={() => {
|
||||||
|
if (playing) player.pause();
|
||||||
|
else player.play();
|
||||||
|
}}
|
||||||
|
{...tooltip(playing ? t("player.pause") : t("player.play"), true)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [volume, setVolume] = useState(player.volume);
|
||||||
|
useEvent(player, "onVolumeChange", setVolume);
|
||||||
|
// TODO: listen to `player.muted` changes (currently hook does not exists
|
||||||
|
// const [muted, setMuted] = useState(player.muted);
|
||||||
|
const muted = player.muted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
display: { xs: "none", sm: "flex" },
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingRight: ts(1),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={
|
||||||
|
muted || volume === 0
|
||||||
|
? VolumeOff
|
||||||
|
: volume < 25
|
||||||
|
? VolumeMute
|
||||||
|
: volume < 65
|
||||||
|
? VolumeDown
|
||||||
|
: VolumeUp
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
player.muted = !muted;
|
||||||
|
}}
|
||||||
|
{...tooltip(t("player.mute"), true)}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
progress={volume}
|
||||||
|
setProgress={(vol) => {
|
||||||
|
player.volume = vol;
|
||||||
|
}}
|
||||||
|
size={4}
|
||||||
|
{...css({ width: px(100) })}
|
||||||
|
{...tooltip(t("player.volume"), true)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEvent(player, "onStatusChange", (status) => {
|
||||||
|
setLoading(status === "loading");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
position: "absolute",
|
||||||
|
pointerEvents: "none",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bg: (theme) => alpha(theme.colors.black, 0.3),
|
||||||
|
justifyContent: "center",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<CircularProgress {...css({ alignSelf: "center" })} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
111
front/src/ui/player/controls/progress.tsx
Normal file
111
front/src/ui/player/controls/progress.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { TextProps } from "react-native";
|
||||||
|
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||||
|
import { useYoshiki } from "yoshiki/native";
|
||||||
|
import type { Chapter } from "~/models";
|
||||||
|
import { P, Slider } from "~/primitives";
|
||||||
|
|
||||||
|
export const ProgressBar = ({
|
||||||
|
player,
|
||||||
|
// url,
|
||||||
|
chapters,
|
||||||
|
}: {
|
||||||
|
player: VideoPlayer;
|
||||||
|
// url: string;
|
||||||
|
chapters?: Chapter[];
|
||||||
|
}) => {
|
||||||
|
const [duration, setDuration] = useState(player.duration || 100);
|
||||||
|
useEvent(player, "onLoad", (info) => {
|
||||||
|
if (info.duration) setDuration(info.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [progress, setProgress] = useState(player.currentTime || 0);
|
||||||
|
const [buffer, setBuffer] = useState(0);
|
||||||
|
useEvent(player, "onProgress", (progress) => {
|
||||||
|
setProgress(progress.currentTime);
|
||||||
|
setBuffer(progress.bufferDuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [seek, setSeek] = useState<number | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
progress={seek ?? progress}
|
||||||
|
subtleProgress={buffer}
|
||||||
|
max={duration}
|
||||||
|
startSeek={() => {
|
||||||
|
player.pause();
|
||||||
|
}}
|
||||||
|
setProgress={setSeek}
|
||||||
|
endSeek={() => {
|
||||||
|
setProgress(seek!);
|
||||||
|
setSeek(null);
|
||||||
|
setTimeout(player.play, 10);
|
||||||
|
}}
|
||||||
|
// onHover={(progress, layout) => {
|
||||||
|
// setHoverProgress(progress);
|
||||||
|
// setLayout(layout);
|
||||||
|
// }}
|
||||||
|
markers={chapters?.map((x) => x.startTime)}
|
||||||
|
// dataSet={{ tooltipId: "progress-scrubber" }}
|
||||||
|
/>
|
||||||
|
{/* <Tooltip */}
|
||||||
|
{/* id={"progress-scrubber"} */}
|
||||||
|
{/* isOpen={hoverProgress !== null} */}
|
||||||
|
{/* place="top" */}
|
||||||
|
{/* position={{ */}
|
||||||
|
{/* x: layout.x + (layout.width * hoverProgress!) / (duration ?? 1), */}
|
||||||
|
{/* y: layout.y, */}
|
||||||
|
{/* }} */}
|
||||||
|
{/* render={() => */}
|
||||||
|
{/* hoverProgress ? ( */}
|
||||||
|
{/* <ScrubberTooltip */}
|
||||||
|
{/* seconds={hoverProgress} */}
|
||||||
|
{/* chapters={chapters} */}
|
||||||
|
{/* url={url} */}
|
||||||
|
{/* /> */}
|
||||||
|
{/* ) : null */}
|
||||||
|
{/* } */}
|
||||||
|
{/* opacity={1} */}
|
||||||
|
{/* style={{ padding: 0, borderRadius: imageBorderRadius }} */}
|
||||||
|
{/* /> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProgressText = ({
|
||||||
|
player,
|
||||||
|
...props
|
||||||
|
}: { player: VideoPlayer } & TextProps) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
const [progress, setProgress] = useState(player.currentTime || 0);
|
||||||
|
useEvent(player, "onProgress", (progress) => {
|
||||||
|
setProgress(progress.currentTime);
|
||||||
|
});
|
||||||
|
const [duration, setDuration] = useState(player.duration || 100);
|
||||||
|
useEvent(player, "onLoad", (info) => {
|
||||||
|
if (info.duration) setDuration(info.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<P {...css({ alignSelf: "center" }, props)}>
|
||||||
|
{toTimerString(progress, duration)} : {toTimerString(duration)}
|
||||||
|
</P>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toTimerString = (timer?: number, duration?: number) => {
|
||||||
|
if (!duration) duration = timer;
|
||||||
|
if (timer === undefined || Number.isNaN(timer)) return "??:??";
|
||||||
|
|
||||||
|
const h = Math.floor(timer / 3600);
|
||||||
|
const min = Math.floor((timer / 60) % 60);
|
||||||
|
const sec = Math.floor(timer % 60);
|
||||||
|
const fmt = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
|
||||||
|
return h !== 0 || (duration && duration >= 3600)
|
||||||
|
? `${fmt(h)}:${fmt(min)}:${fmt(sec)}`
|
||||||
|
: `${fmt(min)}:${fmt(sec)}`;
|
||||||
|
};
|
||||||
@ -1,24 +1,14 @@
|
|||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import {
|
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
|
||||||
useEvent,
|
|
||||||
useVideoPlayer,
|
|
||||||
VideoView,
|
|
||||||
VideoViewRef,
|
|
||||||
} from "react-native-video";
|
|
||||||
import { entryDisplayNumber } from "~/components/entries";
|
import { entryDisplayNumber } from "~/components/entries";
|
||||||
import { FullVideo, VideoInfo } from "~/models";
|
import { FullVideo, VideoInfo } from "~/models";
|
||||||
import { Head } from "~/primitives";
|
import { ContrastArea, Head } from "~/primitives";
|
||||||
import { useToken } from "~/providers/account-context";
|
import { useToken } from "~/providers/account-context";
|
||||||
import { useLocalSetting } from "~/providers/settings";
|
import { useLocalSetting } from "~/providers/settings";
|
||||||
import { type QueryIdentifier, useFetch } from "~/query";
|
import { type QueryIdentifier, useFetch } from "~/query";
|
||||||
import { useQueryState } from "~/utils";
|
import { useQueryState } from "~/utils";
|
||||||
import { LoadingIndicator } from "./components/hover";
|
import { LoadingIndicator } from "./controls";
|
||||||
|
|
||||||
// import { Hover, LoadingIndicator } from "./components/hover";
|
|
||||||
// import { useVideoKeyboard } from "./keyboard";
|
|
||||||
// import { durationAtom, fullscreenAtom, Video } from "./state";
|
|
||||||
|
|
||||||
const mapMetadata = (item: FullVideo | undefined) => {
|
const mapMetadata = (item: FullVideo | undefined) => {
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
@ -142,8 +132,10 @@ export const Player = () => {
|
|||||||
controls
|
controls
|
||||||
style={StyleSheet.absoluteFillObject}
|
style={StyleSheet.absoluteFillObject}
|
||||||
/>
|
/>
|
||||||
<LoadingIndicator player={player} />
|
<ContrastArea mode="dark">
|
||||||
{/* <Hover {...mapData(data, info, previous, next)} url={`${type}/${slug}`} /> */}
|
<LoadingIndicator player={player} />
|
||||||
|
<Controls player={player} {...metadata} />
|
||||||
|
</ContrastArea>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import {
|
|||||||
progressAtom,
|
progressAtom,
|
||||||
subtitleAtom,
|
subtitleAtom,
|
||||||
volumeAtom,
|
volumeAtom,
|
||||||
} from "./state";
|
} from "./old/statee";
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "play" }
|
| { type: "play" }
|
||||||
141
front/src/ui/player/old/left-buttons.tsx
Normal file
141
front/src/ui/player/old/left-buttons.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
Link,
|
||||||
|
noTouch,
|
||||||
|
tooltip,
|
||||||
|
touchOnly,
|
||||||
|
ts,
|
||||||
|
} from "@kyoo/primitives";
|
||||||
|
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
|
||||||
|
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
|
||||||
|
import SkipNext from "@material-symbols/svg-400/rounded/skip_next-fill.svg";
|
||||||
|
import SkipPrevious from "@material-symbols/svg-400/rounded/skip_previous-fill.svg";
|
||||||
|
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 "../controls";
|
||||||
|
import { playAtom } from "./state";
|
||||||
|
|
||||||
|
export const LeftButtons = ({
|
||||||
|
previousSlug,
|
||||||
|
nextSlug,
|
||||||
|
}: {
|
||||||
|
previousSlug?: string | null;
|
||||||
|
nextSlug?: string | null;
|
||||||
|
}) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||||
|
|
||||||
|
const spacing = css({ marginHorizontal: ts(1) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...css({ flexDirection: "row" })}>
|
||||||
|
<View {...css({ flexDirection: "row" }, noTouch)}>
|
||||||
|
{previousSlug && (
|
||||||
|
<IconButton
|
||||||
|
icon={SkipPrevious}
|
||||||
|
as={Link}
|
||||||
|
href={previousSlug}
|
||||||
|
replace
|
||||||
|
{...tooltip(t("player.previous"), true)}
|
||||||
|
{...spacing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon={isPlaying ? Pause : PlayArrow}
|
||||||
|
onPress={() => setPlay(!isPlaying)}
|
||||||
|
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
|
||||||
|
{...spacing}
|
||||||
|
/>
|
||||||
|
{nextSlug && (
|
||||||
|
<IconButton
|
||||||
|
icon={SkipNext}
|
||||||
|
as={Link}
|
||||||
|
href={nextSlug}
|
||||||
|
replace
|
||||||
|
{...tooltip(t("player.next"), true)}
|
||||||
|
{...spacing}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{Platform.OS === "web" && <VolumeSlider />}
|
||||||
|
</View>
|
||||||
|
<ProgressText {...css({ marginLeft: ts(1) })} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<HoverTouch
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hover && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
icon={SkipPrevious}
|
||||||
|
as={Link}
|
||||||
|
href={previousSlug!}
|
||||||
|
replace
|
||||||
|
size={ts(4)}
|
||||||
|
{...css(
|
||||||
|
[!previousSlug && { opacity: 0, pointerEvents: "none" }],
|
||||||
|
common,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={isPlaying ? Pause : PlayArrow}
|
||||||
|
onPress={() => setPlay(!isPlaying)}
|
||||||
|
size={ts(8)}
|
||||||
|
{...common}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={SkipNext}
|
||||||
|
as={Link}
|
||||||
|
href={nextSlug!}
|
||||||
|
replace
|
||||||
|
size={ts(4)}
|
||||||
|
{...css(
|
||||||
|
[!nextSlug && { opacity: 0, pointerEvents: "none" }],
|
||||||
|
common,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HoverTouch>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -21,8 +21,8 @@
|
|||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "solito/router";
|
import { useRouter } from "solito/router";
|
||||||
import { reducerAtom } from "./keyboard";
|
import { reducerAtom } from "./old/keyboardd";
|
||||||
import { durationAtom, playAtom, progressAtom } from "./state";
|
import { durationAtom, playAtom, progressAtom } from "./old/statee";
|
||||||
|
|
||||||
export const MediaSessionManager = ({
|
export const MediaSessionManager = ({
|
||||||
title,
|
title,
|
||||||
@ -30,8 +30,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { type Stylable, useYoshiki } from "yoshiki/native";
|
import { type Stylable, useYoshiki } from "yoshiki/native";
|
||||||
import { useSubtitleName } from "../../../../packages/ui/src/utils";
|
import { useSubtitleName } from "../../../../packages/ui/src/utils";
|
||||||
import { fullscreenAtom, subtitleAtom } from "../state";
|
import { fullscreenAtom, subtitleAtom } from "./state";
|
||||||
import { AudiosMenu, QualitiesMenu } from "../video";
|
import { AudiosMenu, QualitiesMenu } from "./video";
|
||||||
|
|
||||||
export const RightButtons = ({
|
export const RightButtons = ({
|
||||||
audios,
|
audios,
|
||||||
@ -25,9 +25,9 @@ import { useMemo } from "react";
|
|||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native";
|
import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native";
|
||||||
import { ErrorView } from "../../errors";
|
import { ErrorView } from "../../errors";
|
||||||
import { durationAtom } from "../state";
|
import { durationAtom } from "./state";
|
||||||
import { seekProgressAtom } from "./hover";
|
import { seekProgressAtom } from "../controls";
|
||||||
import { toTimerString } from "./left-buttons";
|
import { toTimerString } from "../controls/left-buttonsttons";
|
||||||
|
|
||||||
type Thumb = {
|
type Thumb = {
|
||||||
from: number;
|
from: number;
|
||||||
@ -33,7 +33,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "./video";
|
import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "../videoideo";
|
||||||
|
|
||||||
export const playAtom = atom(true);
|
export const playAtom = atom(true);
|
||||||
export const loadAtom = atom(false);
|
export const loadAtom = atom(false);
|
||||||
@ -50,8 +50,8 @@ import NativeVideo, {
|
|||||||
SelectedVideoTrackType,
|
SelectedVideoTrackType,
|
||||||
} from "react-native-video";
|
} from "react-native-video";
|
||||||
import { useYoshiki } from "yoshiki/native";
|
import { useYoshiki } from "yoshiki/native";
|
||||||
import { useDisplayName } from "../../../packages/ui/src/utils";
|
import { useDisplayName } from "../../../../packages/ui/src/utils";
|
||||||
import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./state";
|
import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./old/statee";
|
||||||
|
|
||||||
const MimeTypes: Map<string, string> = new Map([
|
const MimeTypes: Map<string, string> = new Map([
|
||||||
["subrip", "application/x-subrip"],
|
["subrip", "application/x-subrip"],
|
||||||
@ -36,9 +36,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { VideoProps } from "react-native-video";
|
import type { VideoProps } from "react-native-video";
|
||||||
import toVttBlob from "srt-webvtt";
|
import toVttBlob from "srt-webvtt";
|
||||||
import { useForceRerender, useYoshiki } from "yoshiki";
|
import { useForceRerender, useYoshiki } from "yoshiki";
|
||||||
import { useDisplayName } from "../../../packages/ui/src/utils";
|
import { useDisplayName } from "../../../../packages/ui/src/utils";
|
||||||
import { MediaSessionManager } from "./media-session";
|
import { MediaSessionManager } from "./old/media-sessionn";
|
||||||
import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./state";
|
import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./old/statee";
|
||||||
|
|
||||||
let hls: Hls | null = null;
|
let hls: Hls | null = null;
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useAtomCallback } from "jotai/utils";
|
import { useAtomCallback } from "jotai/utils";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { playAtom, progressAtom } from "./state";
|
import { playAtom, progressAtom } from "./old/statee";
|
||||||
|
|
||||||
export const WatchStatusObserver = ({
|
export const WatchStatusObserver = ({
|
||||||
type,
|
type,
|
||||||
Loading…
x
Reference in New Issue
Block a user