mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-02-26 05:00:03 -05:00
Reimplement scrubber (#1325)
This commit is contained in:
parent
cbc3388ba9
commit
e9ded5ee11
@ -604,6 +604,49 @@ export const videosReadH = new Elysia({ prefix: "/videos", tags: ["videos"] })
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/thumbnails.vtt",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
const [video] = await db
|
||||
.select({
|
||||
path: videos.path,
|
||||
})
|
||||
.from(videos)
|
||||
.leftJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk))
|
||||
.where(isUuid(id) ? eq(videos.id, id) : eq(entryVideoJoin.slug, id))
|
||||
.limit(1);
|
||||
|
||||
if (!video) {
|
||||
return status(404, {
|
||||
status: 404,
|
||||
message: `No video found with id or slug '${id}'`,
|
||||
});
|
||||
}
|
||||
const path = Buffer.from(video.path, "utf8").toString("base64url");
|
||||
return redirect(`/video/${path}/thumbnails.vtt`);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "Get redirected to the direct stream of the video",
|
||||
},
|
||||
params: t.Object({
|
||||
id: t.String({
|
||||
description: "The id or slug of the video to watch.",
|
||||
example: "made-in-abyss-s1e13",
|
||||
}),
|
||||
}),
|
||||
response: {
|
||||
302: t.Void({
|
||||
description:
|
||||
"Redirected to the [/video/{path}/direct](?api=transcoder#tag/metadata/get/:path/direct) route (of the transcoder)",
|
||||
}),
|
||||
404: {
|
||||
...KError,
|
||||
description: "No video found with the given id or slug.",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.get(
|
||||
":id/direct",
|
||||
async ({ params: { id }, status, redirect }) => {
|
||||
|
||||
@ -9,7 +9,7 @@ import { withUniwind } from "uniwind";
|
||||
import type { KImage } from "~/models";
|
||||
import { useToken } from "~/providers/account-context";
|
||||
import { cn } from "~/utils";
|
||||
import { PosterPlaceholder } from "./image";
|
||||
import { PosterPlaceholder } from "../image/image";
|
||||
|
||||
const ImgBg = withUniwind(EImageBackground);
|
||||
|
||||
@ -7,7 +7,7 @@ import type { YoshikiStyle } from "yoshiki/src/type";
|
||||
import type { KImage } from "~/models";
|
||||
import { useToken } from "~/providers/account-context";
|
||||
import { cn } from "~/utils";
|
||||
import { Skeleton } from "./skeleton";
|
||||
import { Skeleton } from "../skeleton";
|
||||
|
||||
export type YoshikiEnhanced<Style> = Style extends any
|
||||
? {
|
||||
@ -1,24 +1,7 @@
|
||||
/*
|
||||
* 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 { Image, View } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { useToken } from "~/providers/account-context";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
export const Sprite = ({
|
||||
src,
|
||||
@ -30,6 +13,7 @@ export const Sprite = ({
|
||||
rows,
|
||||
columns,
|
||||
style,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
src: string;
|
||||
@ -40,19 +24,32 @@ export const Sprite = ({
|
||||
y: number;
|
||||
rows: number;
|
||||
columns: number;
|
||||
style?: object;
|
||||
}) => {
|
||||
} & ViewProps) => {
|
||||
const { authToken } = useToken();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ width, height, overflow: "hidden", flexGrow: 0, flexShrink: 0 }}
|
||||
className={cn("overflow-hidden", className)}
|
||||
style={[style, { width, height }]}
|
||||
{...props}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
source={{
|
||||
uri: src,
|
||||
// use cookies on web to allow `img` to make the call instead of js
|
||||
headers:
|
||||
authToken && Platform.OS !== "web"
|
||||
? {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
alt={alt}
|
||||
width={width * columns}
|
||||
height={height * rows}
|
||||
style={{ transform: [{ translateX: -x }, { translateY: -y }] }}
|
||||
{...props}
|
||||
style={{
|
||||
width: width * columns,
|
||||
height: height * rows,
|
||||
transform: [{ translateX: -x }, { translateY: -y }],
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
export { Footer, Header, Main, Nav, UL } from "@expo/html-elements";
|
||||
// export * from "./snackbar";
|
||||
export * from "./alert";
|
||||
export * from "./avatar";
|
||||
export * from "./button";
|
||||
@ -7,8 +6,9 @@ export * from "./chip";
|
||||
export * from "./container";
|
||||
export * from "./divider";
|
||||
export * from "./icons";
|
||||
export * from "./image";
|
||||
export * from "./image-background";
|
||||
export * from "./image/image";
|
||||
export * from "./image/image-background";
|
||||
export * from "./image/sprite";
|
||||
export * from "./input";
|
||||
export * from "./links";
|
||||
export * from "./menu";
|
||||
|
||||
@ -1,132 +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 { usePortal } from "@gorhom/portal";
|
||||
import {
|
||||
createContext,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { percent, px } from "yoshiki/native";
|
||||
import { Button } from "./button";
|
||||
import { imageBorderRadius } from "./constants";
|
||||
import { P } from "./text";
|
||||
import { SwitchVariant } from "./themes";
|
||||
import { ts } from "./utils";
|
||||
|
||||
export type Snackbar = {
|
||||
key?: string;
|
||||
label: string;
|
||||
duration: number;
|
||||
actions?: Action[];
|
||||
};
|
||||
|
||||
export type Action = {
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
action: () => void;
|
||||
};
|
||||
|
||||
const SnackbarContext = createContext<(snackbar: Snackbar) => void>(null!);
|
||||
|
||||
export const SnackbarProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactElement | ReactElement[];
|
||||
}) => {
|
||||
const { addPortal, removePortal } = usePortal();
|
||||
const snackbars = useRef<Snackbar[]>([]);
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const createSnackbar = useCallback(
|
||||
(snackbar: Snackbar) => {
|
||||
if (snackbar.key)
|
||||
snackbars.current = snackbars.current.filter(
|
||||
(x) => snackbar.key !== x.key,
|
||||
);
|
||||
snackbars.current.unshift(snackbar);
|
||||
|
||||
if (timeout.current) return;
|
||||
const updatePortal = () => {
|
||||
const top = snackbars.current.pop();
|
||||
if (!top) {
|
||||
timeout.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { key, ...props } = top;
|
||||
addPortal("snackbar", <Snackbar key={key} {...props} />);
|
||||
timeout.current = setTimeout(() => {
|
||||
removePortal("snackbar");
|
||||
updatePortal();
|
||||
}, snackbar.duration * 1000);
|
||||
};
|
||||
updatePortal();
|
||||
},
|
||||
[addPortal, removePortal],
|
||||
);
|
||||
|
||||
return (
|
||||
<SnackbarContext.Provider value={createSnackbar}>
|
||||
{children}
|
||||
</SnackbarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSnackbar = () => {
|
||||
return useContext(SnackbarContext);
|
||||
};
|
||||
|
||||
const Snackbar = ({ label, actions }: Snackbar) => {
|
||||
// TODO: take navbar height into account for setting the position of the snacbar.
|
||||
return (
|
||||
<SwitchVariant>
|
||||
{({ css }) => (
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: ts(4),
|
||||
})}
|
||||
>
|
||||
<View
|
||||
{...css({
|
||||
bg: (theme) => theme.background,
|
||||
maxWidth: { sm: percent(75), md: percent(45), lg: px(500) },
|
||||
margin: ts(1),
|
||||
padding: ts(1),
|
||||
flexDirection: "row",
|
||||
borderRadius: imageBorderRadius,
|
||||
})}
|
||||
>
|
||||
<P {...css({ flexGrow: 1, flexShrink: 1 })}>{label}</P>
|
||||
{actions?.map((x, i) => (
|
||||
<Button key={i} text={x.label} icon={x.icon} onPress={x.action} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</SwitchVariant>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Tooltip as RTooltip } from "react-tooltip";
|
||||
import { useTheme } from "yoshiki/native";
|
||||
|
||||
@ -10,7 +11,7 @@ export const tooltip = (tooltip: string, up?: boolean) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const Tooltip = () => {
|
||||
export const Tooltip = (props: ComponentProps<typeof RTooltip>) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<RTooltip
|
||||
@ -20,6 +21,9 @@ export const Tooltip = () => {
|
||||
background: theme.contrast,
|
||||
color: theme.alternate.contrast,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { RTooltip };
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 type { ComponentProps } from "react";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Platform,
|
||||
@ -21,6 +21,7 @@ import {
|
||||
useIsTouch,
|
||||
} from "~/primitives";
|
||||
import { cn } from "~/utils";
|
||||
import { BottomScrubber } from "../scrubber";
|
||||
import { FullscreenButton, PlayButton, VolumeSlider } from "./misc";
|
||||
import { ProgressBar, ProgressText } from "./progress";
|
||||
import { AudioMenu, QualityMenu, SubtitleMenu, VideoMenu } from "./tracks-menu";
|
||||
@ -44,6 +45,9 @@ export const BottomControls = ({
|
||||
next?: string | null;
|
||||
setMenu: (isOpen: boolean) => void;
|
||||
} & ViewProps) => {
|
||||
const [seek, setSeek] = useState<number | null>(null);
|
||||
const bottomSeek = Platform.OS !== "web" && seek !== null;
|
||||
|
||||
return (
|
||||
<View className={cn("flex-row p-2", className)} {...props}>
|
||||
<View className="m-4 w-1/5 max-w-50 max-sm:hidden">
|
||||
@ -58,20 +62,30 @@ export const BottomControls = ({
|
||||
)}
|
||||
</View>
|
||||
<View className="my-1 mr-4 flex-1 max-sm:ml-4 sm:my-6">
|
||||
{name ? (
|
||||
<H2 numberOfLines={1} className="pb-2 text-slate-200">
|
||||
{name}
|
||||
</H2>
|
||||
) : (
|
||||
<Skeleton className="h-8 w-1/5" />
|
||||
)}
|
||||
<ProgressBar player={player} chapters={chapters} />
|
||||
<ControlButtons
|
||||
{!bottomSeek &&
|
||||
(name ? (
|
||||
<H2 numberOfLines={1} className="pb-2 text-slate-200">
|
||||
{name}
|
||||
</H2>
|
||||
) : (
|
||||
<Skeleton className="h-8 w-1/5" />
|
||||
))}
|
||||
<ProgressBar
|
||||
player={player}
|
||||
previous={previous}
|
||||
next={next}
|
||||
setMenu={setMenu}
|
||||
chapters={chapters}
|
||||
seek={seek}
|
||||
setSeek={setSeek}
|
||||
/>
|
||||
{bottomSeek ? (
|
||||
<BottomScrubber player={player} seek={seek} chapters={chapters} />
|
||||
) : (
|
||||
<ControlButtons
|
||||
player={player}
|
||||
previous={previous}
|
||||
next={next}
|
||||
setMenu={setMenu}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -1,20 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import { type CSSProperties, useState } from "react";
|
||||
import type { TextProps } from "react-native";
|
||||
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||
import { useResolveClassNames } from "uniwind";
|
||||
import type { Chapter } from "~/models";
|
||||
import { P, Slider } from "~/primitives";
|
||||
import { P, Slider, Tooltip } from "~/primitives";
|
||||
import { useFetch } from "~/query";
|
||||
import { Info } from "~/ui/info";
|
||||
import { cn, useQueryState } from "~/utils";
|
||||
import { ScrubberTooltip } from "../scrubber";
|
||||
|
||||
export const ProgressBar = ({
|
||||
player,
|
||||
// url,
|
||||
chapters,
|
||||
seek,
|
||||
setSeek,
|
||||
}: {
|
||||
player: VideoPlayer;
|
||||
// url: string;
|
||||
chapters?: Chapter[];
|
||||
seek: number | null;
|
||||
setSeek: (v: number | null) => void;
|
||||
}) => {
|
||||
const [slug] = useQueryState<string>("slug", undefined!);
|
||||
const { data } = useFetch(Info.infoQuery(slug));
|
||||
@ -26,7 +30,9 @@ export const ProgressBar = ({
|
||||
setBuffer(progress.bufferDuration);
|
||||
});
|
||||
|
||||
const [seek, setSeek] = useState<number | null>(null);
|
||||
const [hoverProgress, setHoverProgress] = useState<number | null>(null);
|
||||
const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
||||
const percent = hoverProgress! / (data?.durationSeconds ?? 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -43,33 +49,40 @@ export const ProgressBar = ({
|
||||
setTimeout(() => player.play(), 10);
|
||||
setSeek(null);
|
||||
}}
|
||||
// onHover={(progress, layout) => {
|
||||
// setHoverProgress(progress);
|
||||
// setLayout(layout);
|
||||
// }}
|
||||
onHover={(progress, layout) => {
|
||||
setHoverProgress(progress);
|
||||
setLayout(layout);
|
||||
}}
|
||||
markers={chapters?.map((x) => x.startTime)}
|
||||
// dataSet={{ tooltipId: "progress-scrubber" }}
|
||||
// @ts-expect-error dataSet is web only and not typed
|
||||
dataSet={{ tooltipId: "progress-scrubber" }}
|
||||
/>
|
||||
<Tooltip
|
||||
id={"progress-scrubber"}
|
||||
isOpen={hoverProgress !== null}
|
||||
// not a real fix, we should fix it upstream
|
||||
place={percent > 80 ? "top-end" : "top"}
|
||||
position={{
|
||||
x: layout.x + layout.width * percent,
|
||||
y: layout.y,
|
||||
}}
|
||||
render={() =>
|
||||
hoverProgress ? (
|
||||
<ScrubberTooltip
|
||||
seconds={hoverProgress}
|
||||
chapters={chapters}
|
||||
videoSlug={slug}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
opacity={1}
|
||||
style={{
|
||||
padding: 0,
|
||||
...(useResolveClassNames(
|
||||
cn("rounded bg-slate-200"),
|
||||
) as CSSProperties),
|
||||
}}
|
||||
/>
|
||||
{/* <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 }} */}
|
||||
{/* /> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -94,7 +107,7 @@ export const ProgressText = ({
|
||||
);
|
||||
};
|
||||
|
||||
const toTimerString = (timer?: number, duration?: number) => {
|
||||
export const toTimerString = (timer?: number, duration?: number) => {
|
||||
if (!duration) duration = timer;
|
||||
if (timer === undefined || !Number.isFinite(timer)) return "??:??";
|
||||
|
||||
|
||||
@ -1,262 +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 {
|
||||
type Chapter,
|
||||
imageFn,
|
||||
type QueryIdentifier,
|
||||
useFetch,
|
||||
} from "@kyoo/models";
|
||||
import { imageBorderRadius, P, Sprite, ts } from "@kyoo/primitives";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
percent,
|
||||
px,
|
||||
type Theme,
|
||||
useForceRerender,
|
||||
useYoshiki,
|
||||
} from "yoshiki/native";
|
||||
import { seekProgressAtom } from "../controls";
|
||||
import { toTimerString } from "../controls/left-buttonsttons";
|
||||
import { durationAtom } from "./state";
|
||||
|
||||
type Thumb = {
|
||||
from: number;
|
||||
to: number;
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const parseTs = (time: string) => {
|
||||
const times = time.split(":");
|
||||
return (
|
||||
(Number.parseInt(times[0], 10) * 3600 +
|
||||
Number.parseInt(times[1], 10) * 60 +
|
||||
Number.parseFloat(times[2])) *
|
||||
1000
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrubber = (url: string) => {
|
||||
const { data, error } = useFetch(useScrubber.query(url));
|
||||
// TODO: put the info here on the react-query cache to prevent multiples runs of this
|
||||
const info = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const lines = data.split("\n").filter((x) => x);
|
||||
lines.shift();
|
||||
/* lines now contains something like
|
||||
*
|
||||
* 00:00:00.000 --> 00:00:01.000
|
||||
* image1.png#xywh=0,0,190,120
|
||||
* 00:00:01.000 --> 00:00:02.000
|
||||
* image1.png#xywh=190,0,190,120
|
||||
*/
|
||||
|
||||
const ret = new Array<Thumb>(lines.length / 2);
|
||||
for (let i = 0; i < ret.length; i++) {
|
||||
const times = lines[i * 2].split(" --> ");
|
||||
const url = lines[i * 2 + 1].split("#xywh=");
|
||||
const xywh = url[1].split(",").map((x) => Number.parseInt(x, 10));
|
||||
ret[i] = {
|
||||
from: parseTs(times[0]),
|
||||
to: parseTs(times[1]),
|
||||
url: imageFn(url[0]),
|
||||
x: xywh[0],
|
||||
y: xywh[1],
|
||||
width: xywh[2],
|
||||
height: xywh[3],
|
||||
};
|
||||
}
|
||||
return ret;
|
||||
}, [data]);
|
||||
|
||||
const last = info?.[info.length - 1];
|
||||
return {
|
||||
info,
|
||||
error,
|
||||
stats: last
|
||||
? {
|
||||
rows: last.y / last.height + 1,
|
||||
columns: Math.max(...info.map((x) => x.x)) / last.width + 1,
|
||||
width: last.width,
|
||||
height: last.height,
|
||||
}
|
||||
: null,
|
||||
} as const;
|
||||
};
|
||||
|
||||
useScrubber.query = (url: string): QueryIdentifier<string> => ({
|
||||
path: [url, "thumbnails.vtt"],
|
||||
parser: null!,
|
||||
options: {
|
||||
plainText: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const ScrubberTooltip = ({
|
||||
url,
|
||||
chapters,
|
||||
seconds,
|
||||
}: {
|
||||
url: string;
|
||||
chapters?: Chapter[];
|
||||
seconds: number;
|
||||
}) => {
|
||||
const { info, stats } = useScrubber(url);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
const current =
|
||||
info.findLast((x) => x.from <= seconds * 1000 && seconds * 1000 < x.to) ??
|
||||
info.findLast(() => true);
|
||||
const chapter = chapters?.findLast(
|
||||
(x) => x.startTime <= seconds && seconds < x.endTime,
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
{...css({
|
||||
justifyContent: "center",
|
||||
borderRadius: imageBorderRadius,
|
||||
overflow: "hidden",
|
||||
})}
|
||||
>
|
||||
{current && (
|
||||
<Sprite
|
||||
src={current.url}
|
||||
alt={""}
|
||||
width={current.width}
|
||||
height={current.height}
|
||||
x={current.x}
|
||||
y={current.y}
|
||||
columns={stats!.columns}
|
||||
rows={stats!.rows}
|
||||
/>
|
||||
)}
|
||||
<P {...css({ textAlign: "center" })}>
|
||||
{toTimerString(seconds)} {chapter && `- ${chapter.name}`}
|
||||
</P>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
let scrubberWidth = 0;
|
||||
|
||||
export const BottomScrubber = ({
|
||||
url,
|
||||
chapters,
|
||||
}: {
|
||||
url: string;
|
||||
chapters?: Chapter[];
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
const { info, stats } = useScrubber(url);
|
||||
const rerender = useForceRerender();
|
||||
|
||||
const progress = useAtomValue(seekProgressAtom) ?? 0;
|
||||
const duration = useAtomValue(durationAtom) ?? 1;
|
||||
|
||||
const width = stats?.width ?? 1;
|
||||
const chapter = chapters?.findLast(
|
||||
(x) => x.startTime <= progress && progress < x.endTime,
|
||||
);
|
||||
return (
|
||||
<View {...css({ overflow: "hidden" })}>
|
||||
<View
|
||||
{...(Platform.OS === "web"
|
||||
? css({ transform: "translateX(50%)" })
|
||||
: {
|
||||
// react-native does not support translateX by percentage so we simulate it
|
||||
style: { transform: [{ translateX: scrubberWidth / 2 }] },
|
||||
onLayout: (e) => {
|
||||
if (!e.nativeEvent.layout.width) return;
|
||||
scrubberWidth = e.nativeEvent.layout.width;
|
||||
rerender();
|
||||
},
|
||||
})}
|
||||
>
|
||||
<View
|
||||
{...css(
|
||||
{ flexDirection: "row" },
|
||||
{
|
||||
style: {
|
||||
transform: `translateX(${
|
||||
(progress / duration) * -width * info.length - width / 2
|
||||
}px)`,
|
||||
},
|
||||
},
|
||||
)}
|
||||
>
|
||||
{info.map((thumb) => (
|
||||
<Sprite
|
||||
key={thumb.to}
|
||||
src={thumb.url}
|
||||
alt=""
|
||||
width={thumb.width}
|
||||
height={thumb.height}
|
||||
x={thumb.x}
|
||||
y={thumb.y}
|
||||
columns={stats!.columns}
|
||||
rows={stats!.rows}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: percent(50),
|
||||
right: percent(50),
|
||||
width: px(3),
|
||||
bg: (theme) => theme.colors.white,
|
||||
})}
|
||||
/>
|
||||
<View
|
||||
{...css({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
})}
|
||||
>
|
||||
<P
|
||||
{...css({
|
||||
textAlign: "center",
|
||||
color: (theme: Theme) => theme.colors.white,
|
||||
bg: (theme) => theme.darkOverlay,
|
||||
padding: ts(0.5),
|
||||
borderRadius: imageBorderRadius,
|
||||
})}
|
||||
>
|
||||
{toTimerString(progress)}
|
||||
{chapter && `\n${chapter.name}`}
|
||||
</P>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
183
front/src/ui/player/scrubber.tsx
Normal file
183
front/src/ui/player/scrubber.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useEvent, type VideoPlayer } from "react-native-video";
|
||||
import type { Chapter } from "~/models";
|
||||
import { P, Sprite } from "~/primitives";
|
||||
import { useToken } from "~/providers/account-context";
|
||||
import { type QueryIdentifier, useFetch } from "~/query";
|
||||
import { useQueryState } from "~/utils";
|
||||
import { toTimerString } from "./controls/progress";
|
||||
|
||||
type Thumb = {
|
||||
from: number;
|
||||
to: number;
|
||||
url: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const parseTs = (time: string) => {
|
||||
const times = time.split(":");
|
||||
return (
|
||||
(Number.parseInt(times[0], 10) * 3600 +
|
||||
Number.parseInt(times[1], 10) * 60 +
|
||||
Number.parseFloat(times[2])) *
|
||||
1000
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrubber = (videoSlug: string) => {
|
||||
const { apiUrl } = useToken();
|
||||
const { data } = useFetch(useScrubber.query(videoSlug));
|
||||
|
||||
const info = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const lines = data.split("\n").filter((x) => x);
|
||||
lines.shift();
|
||||
/* lines now contains something like
|
||||
*
|
||||
* 00:00:00.000 --> 00:00:01.000
|
||||
* image1.png#xywh=0,0,190,120
|
||||
* 00:00:01.000 --> 00:00:02.000
|
||||
* image1.png#xywh=190,0,190,120
|
||||
*/
|
||||
|
||||
const ret = new Array<Thumb>(lines.length / 2);
|
||||
for (let i = 0; i < ret.length; i++) {
|
||||
const times = lines[i * 2].split(" --> ");
|
||||
const url = lines[i * 2 + 1].split("#xywh=");
|
||||
const xywh = url[1].split(",").map((x) => Number.parseInt(x, 10));
|
||||
ret[i] = {
|
||||
from: parseTs(times[0]),
|
||||
to: parseTs(times[1]),
|
||||
url: `${apiUrl}${url[0]}`,
|
||||
x: xywh[0],
|
||||
y: xywh[1],
|
||||
width: xywh[2],
|
||||
height: xywh[3],
|
||||
};
|
||||
}
|
||||
return ret;
|
||||
}, [apiUrl, data]);
|
||||
|
||||
const last = info?.[info.length - 1];
|
||||
return {
|
||||
info,
|
||||
stats: last
|
||||
? {
|
||||
rows: last.y / last.height + 1,
|
||||
columns: Math.max(...info.map((x) => x.x)) / last.width + 1,
|
||||
width: last.width,
|
||||
height: last.height,
|
||||
}
|
||||
: null,
|
||||
} as const;
|
||||
};
|
||||
|
||||
useScrubber.query = (videoSlug: string): QueryIdentifier<string> => ({
|
||||
path: ["api", "videos", videoSlug, "thumbnails.vtt"],
|
||||
parser: null!,
|
||||
options: {
|
||||
plainText: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const ScrubberTooltip = ({
|
||||
videoSlug,
|
||||
chapters,
|
||||
seconds,
|
||||
}: {
|
||||
videoSlug: string;
|
||||
chapters?: Chapter[];
|
||||
seconds: number;
|
||||
}) => {
|
||||
const { info, stats } = useScrubber(videoSlug);
|
||||
|
||||
const current =
|
||||
info.findLast((x) => x.from <= seconds * 1000 && seconds * 1000 < x.to) ??
|
||||
info.findLast(() => true);
|
||||
const chapter = chapters?.findLast(
|
||||
(x) => x.startTime <= seconds && seconds < x.endTime,
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="justify-center overflow-hidden rounded bg-slate-200">
|
||||
{current && (
|
||||
<Sprite
|
||||
src={current.url}
|
||||
alt={""}
|
||||
width={current.width}
|
||||
height={current.height}
|
||||
x={current.x}
|
||||
y={current.y}
|
||||
columns={stats!.columns}
|
||||
rows={stats!.rows}
|
||||
/>
|
||||
)}
|
||||
<P className="text-center">
|
||||
{toTimerString(seconds)} {chapter && `- ${chapter.name}`}
|
||||
</P>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const BottomScrubber = ({
|
||||
chapters,
|
||||
seek,
|
||||
player,
|
||||
}: {
|
||||
chapters?: Chapter[];
|
||||
seek: number;
|
||||
player: VideoPlayer;
|
||||
}) => {
|
||||
const [slug] = useQueryState<string>("slug", undefined!);
|
||||
const { info, stats } = useScrubber(slug);
|
||||
|
||||
const [duration, setDuration] = useState(player.duration);
|
||||
useEvent(player, "onLoad", (info) => {
|
||||
if (info.duration) setDuration(info.duration);
|
||||
});
|
||||
|
||||
const width = stats?.width ?? 1;
|
||||
const chapter = chapters?.findLast(
|
||||
(x) => x.startTime <= seek && seek < x.endTime,
|
||||
);
|
||||
return (
|
||||
<View className="overflow-hidden">
|
||||
<View className="flex-1 translate-x-1/2">
|
||||
<View
|
||||
className="flex-1 flex-row"
|
||||
style={{
|
||||
transform: `translateX(${
|
||||
(seek / duration) * -width * info.length - width / 2
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{info.map((thumb) => (
|
||||
<Sprite
|
||||
key={thumb.to}
|
||||
src={thumb.url}
|
||||
alt=""
|
||||
width={thumb.width}
|
||||
height={thumb.height}
|
||||
x={thumb.x}
|
||||
y={thumb.y}
|
||||
columns={stats!.columns}
|
||||
rows={stats!.rows}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View className="absolute top-0 right-1/2 bottom-0 left-1/2 w-1 bg-slate-200" />
|
||||
<View className="absolute inset-0 items-center">
|
||||
<P className="rounded bg-slate-800 p-1 text-center text-slate-200 dark:text-slate-200">
|
||||
{toTimerString(seek)}
|
||||
{chapter && `\n${chapter.name}`}
|
||||
</P>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user