Fix player controls style

This commit is contained in:
Zoe Roux 2025-07-26 23:09:24 +02:00
parent a52807c565
commit 8e060f0c55
No known key found for this signature in database
10 changed files with 120 additions and 123 deletions

View File

@ -27,9 +27,10 @@ export const Video = z.object({
// Name of the tool that made the guess // Name of the tool that made the guess
from: z.string(), from: z.string(),
get history() { // Adding that results in an infinite recursion
return z.array(Video.shape.guess.omit({ history: true })).default([]); // get history() {
}, // return z.array(Video.shape.guess.omit({ history: true })).default([]);
// },
}), }),
createdAt: zdate(), createdAt: zdate(),
updatedAt: zdate(), updatedAt: zdate(),

View File

@ -25,7 +25,7 @@ export const ImageBackground = ({
layout: ImageLayout; layout: ImageLayout;
children: ReactNode; children: ReactNode;
}) => { }) => {
const { css } = useYoshiki(); const { css, theme } = useYoshiki();
const { apiUrl, authToken } = useToken(); const { apiUrl, authToken } = useToken();
return ( return (
@ -42,7 +42,10 @@ export const ImageBackground = ({
}} }}
placeholder={{ blurhash: src?.blurhash }} placeholder={{ blurhash: src?.blurhash }}
accessibilityLabel={alt} accessibilityLabel={alt}
{...(css([layout, { overflow: "hidden" }], props) as any)} {...(css(
[layout, { overflow: "hidden", backgroundColor: theme.overlay0 }],
props,
) as any)}
/> />
); );
}; };

View File

@ -34,7 +34,7 @@ export const Image = ({
style?: ImageStyle; style?: ImageStyle;
layout: ImageLayout; layout: ImageLayout;
}) => { }) => {
const { css } = useYoshiki(); const { css, theme } = useYoshiki();
const { apiUrl, authToken } = useToken(); const { apiUrl, authToken } = useToken();
return ( return (
@ -51,7 +51,10 @@ export const Image = ({
}} }}
placeholder={{ blurhash: src?.blurhash }} placeholder={{ blurhash: src?.blurhash }}
accessibilityLabel={alt} accessibilityLabel={alt}
{...(css([layout, { borderRadius: 6 }], props) as any)} {...(css(
[layout, { borderRadius: 6, backgroundColor: theme.overlay0 }],
props,
) as any)}
/> />
); );
}; };

View File

@ -108,7 +108,6 @@ export const Skeleton = ({
colors={["transparent", theme.overlay1, "transparent"]} colors={["transparent", theme.overlay1, "transparent"]}
style={[ style={[
StyleSheet.absoluteFillObject, StyleSheet.absoluteFillObject,
{ transform: [{ translateX: -width.value }] },
animated, animated,
]} ]}
/> />

View File

@ -11,7 +11,7 @@ import {
tooltip, tooltip,
} from "~/primitives"; } from "~/primitives";
export const Back = ({ name, ...props }: { name: string } & ViewProps) => { export const Back = ({ name, ...props }: { name?: string } & ViewProps) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@ -35,6 +35,7 @@ export const Back = ({ name, ...props }: { name: string } & ViewProps) => {
onPress={router.back} onPress={router.back}
{...tooltip(t("player.back"))} {...tooltip(t("player.back"))}
/> />
{name ? (
<H1 <H1
{...css({ {...css({
alignSelf: "center", alignSelf: "center",
@ -44,40 +45,9 @@ export const Back = ({ name, ...props }: { name: string } & ViewProps) => {
> >
{name} {name}
</H1> </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) })} /> <Skeleton {...css({ width: rem(5) })} />
)}
</View> </View>
); );
}; };

View File

@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native"; import { Platform, View, type ViewProps } from "react-native";
import type { VideoPlayer } from "react-native-video"; import type { VideoPlayer } from "react-native-video";
import { percent, useYoshiki } from "yoshiki/native"; import { percent, rem, useYoshiki } from "yoshiki/native";
import type { Chapter, KImage } from "~/models"; import type { Chapter, KImage } from "~/models";
import { import {
H2, H2,
@ -12,6 +12,7 @@ import {
Link, Link,
type Menu, type Menu,
Poster, Poster,
Skeleton,
tooltip, tooltip,
ts, ts,
useIsTouch, useIsTouch,
@ -31,11 +32,11 @@ export const BottomControls = ({
...props ...props
}: { }: {
player: VideoPlayer; player: VideoPlayer;
poster: KImage; poster?: KImage | null;
name: string; name?: string;
chapters: Chapter[]; chapters: Chapter[];
previous: string | null; previous?: string | null;
next: string | null; next?: string | null;
setMenu: (isOpen: boolean) => void; setMenu: (isOpen: boolean) => void;
} & ViewProps) => { } & ViewProps) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -57,25 +58,34 @@ export const BottomControls = ({
position: "relative", position: "relative",
})} })}
> >
{poster !== undefined ? (
<Poster <Poster
src={poster} src={poster}
quality="low" quality="low"
layout={{ width: percent(100) }} layout={{ width: percent(100) }}
{...(css({ position: "absolute", bottom: 0 }) as any)} {...(css({ position: "absolute", bottom: 0 }) as any)}
/> />
) : (
<Poster.Loader
layout={{ width: percent(100) }}
{...(css({ position: "absolute", bottom: 0 }) as any)}
/>
)}
</View> </View>
<View <View
{...css({ {...css({
marginLeft: { xs: ts(0.5), sm: ts(3) }, marginHorizontal: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column", flexDirection: "column",
flexGrow: 1, flex: 1,
flexShrink: 1,
maxWidth: percent(100),
})} })}
> >
{name ? (
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}> <H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
name {name}
</H2> </H2>
) : (
<Skeleton {...css({ width: rem(15), height: rem(2) })} />
)}
<ProgressBar player={player} chapters={chapters} /> <ProgressBar player={player} chapters={chapters} />
<ControlButtons <ControlButtons
player={player} player={player}
@ -96,8 +106,8 @@ const ControlButtons = ({
...props ...props
}: { }: {
player: VideoPlayer; player: VideoPlayer;
previous: string | null; previous?: string | null;
next: string | null; next?: string | null;
setMenu: (isOpen: boolean) => void; setMenu: (isOpen: boolean) => void;
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
@ -116,7 +126,7 @@ const ControlButtons = ({
{...css( {...css(
{ {
flexDirection: "row", flexDirection: "row",
flexGrow: 1, flex: 1,
justifyContent: "space-between", justifyContent: "space-between",
flexWrap: "wrap", flexWrap: "wrap",
}, },
@ -124,7 +134,7 @@ const ControlButtons = ({
)} )}
> >
<View {...css({ flexDirection: "row" })}> <View {...css({ flexDirection: "row" })}>
{isTouch && ( {!isTouch && (
<View {...css({ flexDirection: "row" })}> <View {...css({ flexDirection: "row" })}>
{previous && ( {previous && (
<IconButton <IconButton

View File

@ -1,5 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import { StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { VideoPlayer } from "react-native-video"; import type { VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import type { Chapter, KImage } from "~/models"; import type { Chapter, KImage } from "~/models";
@ -11,22 +13,23 @@ import { TouchControls } from "./touch";
export const Controls = ({ export const Controls = ({
player, player,
title, name,
subTitle,
poster, poster,
subName,
chapters, chapters,
previous, previous,
next, next,
}: { }: {
player: VideoPlayer; player: VideoPlayer;
title: string; name?: string;
subTitle: string; poster?: KImage | null;
poster: KImage; subName?: string;
chapters: Chapter[]; chapters: Chapter[];
previous: string | null; previous?: string | null;
next: string | null; next?: string | null;
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const insets = useSafeAreaInsets();
const isTouch = useIsTouch(); const isTouch = useIsTouch();
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
@ -44,9 +47,13 @@ export const Controls = ({
} satisfies ViewProps; } satisfies ViewProps;
return ( return (
<TouchControls player={player} forceShow={hover || menuOpenned}> <TouchControls
player={player}
forceShow={hover || menuOpenned}
{...css(StyleSheet.absoluteFillObject)}
>
<Back <Back
name={title} name={name}
{...css( {...css(
{ {
// pointerEvents: "auto", // pointerEvents: "auto",
@ -55,6 +62,9 @@ export const Controls = ({
left: 0, left: 0,
right: 0, right: 0,
bg: (theme) => theme.darkOverlay, bg: (theme) => theme.darkOverlay,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
}, },
hoverControls, hoverControls,
)} )}
@ -64,7 +74,7 @@ export const Controls = ({
)} )}
<BottomControls <BottomControls
player={player} player={player}
name={subTitle} name={subName}
poster={poster} poster={poster}
chapters={chapters} chapters={chapters}
previous={previous} previous={previous}
@ -79,6 +89,9 @@ export const Controls = ({
left: 0, left: 0,
right: 0, right: 0,
bg: (theme) => theme.darkOverlay, bg: (theme) => theme.darkOverlay,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: insets.bottom,
}, },
hoverControls, hoverControls,
)} )}

View File

@ -39,9 +39,9 @@ export const ProgressBar = ({
}} }}
setProgress={setSeek} setProgress={setSeek}
endSeek={() => { endSeek={() => {
setProgress(seek!); player.seekTo(seek!);
setSeek(null);
setTimeout(player.play, 10); setTimeout(player.play, 10);
setSeek(null);
}} }}
// onHover={(progress, layout) => { // onHover={(progress, layout) => {
// setHoverProgress(progress); // setHoverProgress(progress);

View File

@ -5,7 +5,7 @@ import {
Pressable, Pressable,
type PressableProps, type PressableProps,
} from "react-native"; } from "react-native";
import type { VideoPlayer } from "react-native-video"; import { useEvent, type VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { useIsTouch } from "~/primitives"; import { useIsTouch } from "~/primitives";
@ -18,9 +18,14 @@ export const TouchControls = ({
const { css } = useYoshiki(); const { css } = useYoshiki();
const isTouch = useIsTouch(); const isTouch = useIsTouch();
const [_show, setShow] = useState(true); const [playing, setPlay] = useState(player.isPlaying);
useEvent(player, "onPlaybackStateChange", (status) => {
setPlay(status.isPlaying);
});
const [_show, setShow] = useState(false);
const hideTimeout = useRef<NodeJS.Timeout | null>(null); const hideTimeout = useRef<NodeJS.Timeout | null>(null);
const shouldShow = forceShow || _show; const shouldShow = forceShow || _show || !playing;
const show = useCallback((val: boolean = true) => { const show = useCallback((val: boolean = true) => {
setShow(val); setShow(val);
if (hideTimeout.current) clearTimeout(hideTimeout.current); if (hideTimeout.current) clearTimeout(hideTimeout.current);

View File

@ -10,31 +10,15 @@ import { type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils"; import { useQueryState } from "~/utils";
import { Controls, LoadingIndicator } from "./controls"; import { Controls, LoadingIndicator } from "./controls";
const mapMetadata = (item: FullVideo | undefined) => {
if (!item) return null;
// TODO: map current entry using entries' duration & the current playtime
const currentEntry = 0;
const entry = item.entries[currentEntry] ?? item.entries[0];
if (!entry) return null;
return {
currentEntry,
title: `${entry.name} (${entryDisplayNumber(entry)})`,
description: entry.description,
subtitle: item.show!.kind !== "movie" ? item.show!.name : null,
poster: item.show!.poster,
thumbnail: item.show!.thumbnail,
};
};
export const Player = () => { export const Player = () => {
const [slug, setSlug] = useQueryState<string>("slug", undefined!); const [slug, setSlug] = useQueryState<string>("slug", undefined!);
const [start, setStart] = useQueryState<number | undefined>("t", undefined); const [start, setStart] = useQueryState<number | undefined>("t", undefined);
const { data, error } = useFetch(Player.query(slug)); const { data, error } = useFetch(Player.query(slug));
const { data: info, error: infoError } = useFetch(Player.infoQuery(slug)); const { data: info, error: infoError } = useFetch(Player.infoQuery(slug));
const metadata = mapMetadata(data); // TODO: map current entry using entries' duration & the current playtime
const currentEntry = 0;
const entry = data?.entries[currentEntry] ?? data?.entries[0];
const { apiUrl, authToken } = useToken(); const { apiUrl, authToken } = useToken();
const [playMode] = useLocalSetting<"direct" | "hls">("playMode", "direct"); const [playMode] = useLocalSetting<"direct" | "hls">("playMode", "direct");
@ -44,15 +28,15 @@ export const Player = () => {
headers: { headers: {
Authorization: `Bearer ${authToken}`, Authorization: `Bearer ${authToken}`,
}, },
externalSubtitles: info?.subtitles // externalSubtitles: info?.subtitles
.filter((x) => x.link) // .filter((x) => x.link)
.map((x) => ({ // .map((x) => ({
uri: x.link!, // uri: x.link!,
// TODO: translate this `Unknown` // // TODO: translate this `Unknown`
label: x.title ?? "Unknown", // label: x.title ?? "Unknown",
language: x.language ?? "und", // language: x.language ?? "und",
type: x.codec, // type: x.codec,
})), // })),
}, },
(p) => { (p) => {
p.playWhenInactive = true; p.playWhenInactive = true;
@ -111,9 +95,9 @@ export const Player = () => {
}} }}
> >
<Head <Head
title={metadata?.title} title={entry ? `${entry.name} (${entryDisplayNumber(entry)})` : null}
description={metadata?.description} description={entry?.description}
image={metadata?.thumbnail?.high} image={data?.show?.thumbnail?.high}
/> />
<Stack.Screen <Stack.Screen
options={{ options={{
@ -129,15 +113,24 @@ export const Player = () => {
pictureInPicture pictureInPicture
autoEnterPictureInPicture autoEnterPictureInPicture
resizeMode={"contain"} resizeMode={"contain"}
controls
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
/> />
<ContrastArea mode="dark"> <ContrastArea mode="dark">
<LoadingIndicator player={player} /> <LoadingIndicator player={player} />
<Controls <Controls
player={player} player={player}
title={metadata?.title} name={data?.show?.name}
subTitle={metadata?.subtitle} poster={data?.show?.poster}
subName={
entry
? [entryDisplayNumber(entry), entry.name]
.filter((x) => x)
.join(" - ")
: undefined
}
chapters={info?.chapters ?? []}
previous={data?.previous?.video}
next={data?.next?.video}
/> />
</ContrastArea> </ContrastArea>
</View> </View>
@ -147,7 +140,7 @@ export const Player = () => {
Player.query = (slug: string): QueryIdentifier<FullVideo> => ({ Player.query = (slug: string): QueryIdentifier<FullVideo> => ({
path: ["api", "videos", slug], path: ["api", "videos", slug],
params: { params: {
fields: ["next", "previous", "show"], with: ["next", "previous", "show"],
}, },
parser: FullVideo, parser: FullVideo,
}); });