mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-10-31 18:47:11 -04:00
247 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
};
|