Kyoo/front/src/ui/player/components/scrubber.tsx
2025-10-22 09:16:43 +02:00

247 lines
6.0 KiB
TypeScript

/*
* 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, type QueryIdentifier, imageFn, useFetch } from "@kyoo/models";
import { P, Sprite, imageBorderRadius, ts } from "@kyoo/primitives";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { Platform, View } from "react-native";
import { type Theme, percent, px, useForceRerender, useYoshiki } from "yoshiki/native";
import { ErrorView } from "../../errors";
import { durationAtom } from "../state";
import { seekProgressAtom } from "./hover";
import { toTimerString } from "./left-buttons";
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]) * 3600 +
Number.parseInt(times[1]) * 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));
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, error, stats } = useScrubber(url);
const { css } = useYoshiki();
if (error) return <ErrorView error={error} />;
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, error, stats } = useScrubber(url);
const rerender = useForceRerender();
const progress = useAtomValue(seekProgressAtom) ?? 0;
const duration = useAtomValue(durationAtom) ?? 1;
if (error) return <ErrorView error={error} />;
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>
);
};