Reimplement scrubber (#1325)

This commit is contained in:
Zoe Roux 2026-02-22 12:35:40 +01:00 committed by GitHub
parent cbc3388ba9
commit e9ded5ee11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 333 additions and 473 deletions

View File

@ -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 }) => {

View File

@ -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);

View File

@ -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
? {

View File

@ -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>
);

View File

@ -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";

View File

@ -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>
);
};

View File

@ -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 };

View File

@ -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>
);

View File

@ -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 "??:??";

View File

@ -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>
);
};

View 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>
);
};