Implement bottom controls

This commit is contained in:
Zoe Roux 2025-07-25 23:37:40 +02:00
parent bcff909c21
commit d93ce325e9
No known key found for this signature in database
9 changed files with 271 additions and 348 deletions

View File

@ -1,10 +1,4 @@
import type React from "react";
import {
type ComponentProps,
type ComponentType,
type ForwardedRef,
forwardRef,
} from "react";
import type { ComponentProps, ComponentType } from "react";
import { Platform, type PressableProps } from "react-native";
import type { SvgProps } from "react-native-svg";
import type { YoshikiStyle } from "yoshiki";
@ -13,12 +7,6 @@ import { PressableFeedback } from "./links";
import { P } from "./text";
import { type Breakpoint, focusReset, ts } from "./utils";
declare module "react" {
function forwardRef<T, P = {}>(
render: (props: P, ref: React.ForwardedRef<T>) => React.ReactElement | null,
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}
export type Icon = ComponentType<SvgProps>;
type IconProps = {
@ -54,27 +42,21 @@ export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
);
};
export const IconButton = forwardRef(function IconButton<
AsProps = PressableProps,
>(
{
icon,
size,
color,
as,
...asProps
}: IconProps & {
as?: ComponentType<AsProps>;
} & AsProps,
ref: ForwardedRef<unknown>,
) {
export const IconButton = <AsProps = PressableProps>({
icon,
size,
color,
as,
...asProps
}: IconProps & {
as?: ComponentType<AsProps>;
} & AsProps) => {
const { css, theme } = useYoshiki();
const Container = as ?? PressableFeedback;
return (
<Container
ref={ref as any}
focusRipple
{...(css(
{
@ -102,7 +84,7 @@ export const IconButton = forwardRef(function IconButton<
/>
</Container>
);
});
};
export const IconFab = <AsProps = PressableProps>(
props: ComponentProps<typeof IconButton<AsProps>>,

View File

@ -20,11 +20,6 @@ export const Back = ({ name, ...props }: { name: string } & ViewProps) => {
<View
{...css(
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bg: (theme) => theme.darkOverlay,
display: "flex",
flexDirection: "row",
alignItems: "center",

View File

@ -0,0 +1,140 @@
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 { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import type { VideoPlayer } from "react-native-video";
import { percent, useYoshiki } from "yoshiki/native";
import type { Chapter, KImage } from "~/models";
import {
H2,
IconButton,
Link,
noTouch,
Poster,
tooltip,
ts,
} from "~/primitives";
import { FullscreenButton, PlayButton, VolumeSlider } from "./misc";
import { ProgressBar, ProgressText } from "./progress";
import { AudioMenu, QualityMenu, SubtitleMenu } from "./tracks-menu";
export const BottomControls = ({
player,
poster,
name,
chapters,
...props
}: {
player: VideoPlayer;
poster: KImage;
name: string;
chapters: Chapter[];
} & ViewProps) => {
const { css } = useYoshiki();
return (
<View
{...css(
{
flexDirection: "row",
padding: ts(1),
},
props,
)}
>
<View
{...css({
width: "15%",
display: { xs: "none", sm: "flex" },
position: "relative",
})}
>
<Poster
src={poster}
quality="low"
layout={{ width: percent(100) }}
{...(css({ position: "absolute", bottom: 0 }) as any)}
/>
</View>
<View
{...css({
marginLeft: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column",
flexGrow: 1,
flexShrink: 1,
maxWidth: percent(100),
})}
>
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
name
</H2>
<ProgressBar player={player} chapters={chapters} />
<ControlButtons player={player} />
</View>
</View>
);
};
const ControlButtons = ({
player,
previous,
next,
...props
}: {
player: VideoPlayer;
previous: string;
next: string;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const spacing = css({ marginHorizontal: ts(1) });
return (
<View
{...css(
{
flexDirection: "row",
flexGrow: 1,
justifyContent: "space-between",
flexWrap: "wrap",
},
props,
)}
>
<View {...css({ flexDirection: "row" })}>
<View {...css({ flexDirection: "row" }, noTouch)}>
{previous && (
<IconButton
icon={SkipPrevious}
as={Link}
href={previous}
replace
{...tooltip(t("player.previous"), true)}
{...spacing}
/>
)}
<PlayButton player={player} {...spacing} />
{next && (
<IconButton
icon={SkipNext}
as={Link}
href={next}
replace
{...tooltip(t("player.next"), true)}
{...spacing}
/>
)}
{Platform.OS === "web" && <VolumeSlider player={player} />}
</View>
<ProgressText player={player} {...spacing} />
</View>
<View {...css({ flexDirection: "row" })}>
<SubtitleMenu {...(spacing as any)} />
<AudioMenu {...(spacing as any)} />
<QualityMenu {...(spacing as any)} />
{Platform.OS === "web" && <FullscreenButton {...spacing} />}
</View>
</View>
);
};

View File

@ -1,45 +1,14 @@
import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg";
import { useRouter } from "expo-router";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
type ImageStyle,
Platform,
Pressable,
View,
type ViewProps,
} from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { percent, rem, useYoshiki } from "yoshiki/native";
import type { AudioTrack, Chapter, KImage, Subtitle } from "~/models";
import {
alpha,
CircularProgress,
H1,
H2,
IconButton,
Poster,
PressableFeedback,
Skeleton,
Slider,
Tooltip,
tooltip,
ts,
useIsTouch,
} from "~/primitives";
import { LeftButtons } from "./components/left-buttons";
import { RightButtons } from "./components/right-buttons";
import { BottomScrubber, ScrubberTooltip } from "./scrubber";
import type { VideoPlayer } from "react-native-video";
import { useYoshiki } from "yoshiki/native";
import type { KImage } from "~/models";
import { Back } from "./back";
import { BottomControls } from "./bottom-controls";
import { TouchControls } from "./touch";
export const Controls = ({
player,
title,
poster,
}: {
player: VideoPlayer;
title: string;
@ -47,16 +16,11 @@ export const Controls = ({
poster: KImage | null;
}) => {
const { css } = useYoshiki();
// const show = useAtomValue(hoverAtom);
// const setHover = useSetAtom(hoverReasonAtom);
// const isSeeking = useAtomValue(seekingAtom);
// const isTouch = useIsTouch();
// const showBottomSeeker = isSeeking && isTouch;
// <TouchControls previousSlug={previousSlug} nextSlug={nextSlug} />
return (
<View
<TouchControls
player={player}
// onPointerEnter={(e) => {
// if (e.nativeEvent.pointerType === "mouse")
// setHover((x) => ({ ...x, mouseHover: true }));
@ -65,118 +29,33 @@ export const Controls = ({
// if (e.nativeEvent.pointerType === "mouse")
// setHover((x) => ({ ...x, mouseHover: false }));
// }}
{...css({
// TODO: animate show
//display: !show ? "none" : "flex",
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
// box-none does not work on the web while none does not work on android
pointerEvents: Platform.OS === "web" ? "none" : "box-none",
})}
>
<Back
name={title}
{...css({
pointerEvents: "auto",
// pointerEvents: "auto",
position: "absolute",
top: 0,
left: 0,
right: 0,
bg: (theme) => theme.darkOverlay,
})}
/>
<View
<BottomControls
player={player}
name={title}
poster={poster!}
chapters={[]}
{...css({
// Fixed is used because firefox android make the hover disapear under the navigation bar in absolute
position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
// position: Platform.OS === "web" ? ("fixed" as any) : "absolute",
position: "absolute",
bottom: 0,
left: 0,
right: 0,
bg: (theme) => theme.darkOverlay,
flexDirection: "row",
pointerEvents: "auto",
padding: percent(1),
})}
>
<VideoPoster poster={poster} alt={showName} isLoading={isLoading} />
<View
{...css({
marginLeft: { xs: ts(0.5), sm: ts(3) },
flexDirection: "column",
flexGrow: 1,
flexShrink: 1,
maxWidth: percent(100),
})}
>
{!showBottomSeeker && (
<H2 numberOfLines={1} {...css({ paddingBottom: ts(1) })}>
{isLoading ? (
<Skeleton {...css({ width: rem(15), height: rem(2) })} />
) : (
name
)}
</H2>
)}
<ProgressBar chapters={chapters} url={url} />
{showBottomSeeker ? (
<BottomScrubber url={url} chapters={chapters} />
) : (
<View
{...css({
flexDirection: "row",
flexGrow: 1,
justifyContent: "space-between",
flexWrap: "wrap",
})}
>
<LeftButtons previousSlug={previousSlug} nextSlug={nextSlug} />
<RightButtons
subtitles={subtitles}
audios={audios}
fonts={fonts}
onMenuOpen={() => setHover((x) => ({ ...x, menuOpened: true }))}
onMenuClose={() => {
// Disable hover since the menu overlay makes the mouseout unreliable.
setHover((x) => ({
...x,
menuOpened: false,
mouseHover: false,
}));
}}
/>
</View>
)}
</View>
</View>
</View>
);
};
const VideoPoster = ({
poster,
alt,
isLoading,
}: {
poster?: KyooImage | null;
alt?: string;
isLoading: boolean;
}) => {
const { css } = useYoshiki();
return (
<View
{...css({
width: "15%",
display: { xs: "none", sm: "flex" },
position: "relative",
})}
>
<Poster
src={poster}
quality="low"
alt={alt}
forcedLoading={isLoading}
layout={{ width: percent(100) }}
{...(css({ position: "absolute", bottom: 0 }) as { style: ImageStyle })}
/>
</View>
</TouchControls>
);
};

View File

@ -1,12 +1,14 @@
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
import VolumeOff from "@material-symbols/svg-400/rounded/volume_off-fill.svg";
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { useState } from "react";
import { type ComponentProps, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { type PressableProps, View } from "react-native";
import { useEvent, type VideoPlayer } from "react-native-video";
import { px, useYoshiki } from "yoshiki/native";
import {
@ -18,7 +20,12 @@ import {
ts,
} from "~/primitives";
export const PlayButton = ({ player, ...props }: { player: VideoPlayer }) => {
export const PlayButton = ({
player,
...props
}: { player: VideoPlayer } & Partial<
ComponentProps<typeof IconButton<PressableProps>>
>) => {
const { t } = useTranslation();
const [playing, setPlay] = useState(player.isPlaying);
@ -39,6 +46,24 @@ export const PlayButton = ({ player, ...props }: { player: VideoPlayer }) => {
);
};
export const FullscreenButton = (
props: Partial<ComponentProps<typeof IconButton<PressableProps>>>,
) => {
const { t } = useTranslation();
// TODO: actually implement that
const [fullscreen, setFullscreen] = useState(true);
return (
<IconButton
icon={fullscreen ? FullscreenExit : Fullscreen}
onPress={() => console.log("lol")}
{...tooltip(t("player.fullscreen"), true)}
{...props}
/>
);
};
export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => {
const { css } = useYoshiki();
const { t } = useTranslation();

View File

@ -45,6 +45,7 @@ export const TouchControls = ({
return (
<DoublePressable
tabIndex={-1}
onPress={() => {
if (isTouch) {
show(!shouldShow);

View File

@ -0,0 +1,68 @@
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { IconButton, Menu, tooltip } from "~/primitives";
type MenuProps = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
export const SubtitleMenu = (props: Partial<MenuProps>) => {
const { t } = useTranslation();
// {subtitles && subtitles.length > 0 && (
// )}
return (
<Menu
Trigger={IconButton}
icon={ClosedCaption}
{...tooltip(t("player.subtitles"), true)}
{...props}
>
{/* <Menu.Item */}
{/* label={t("player.subtitle-none")} */}
{/* selected={!selectedSubtitle} */}
{/* onSelect={() => setSubtitle(null)} */}
{/* /> */}
{/* {subtitles */}
{/* .filter((x) => !!x.link) */}
{/* .map((x, i) => ( */}
{/* <Menu.Item */}
{/* key={x.index ?? i} */}
{/* label={ */}
{/* x.link ? getSubtitleName(x) : `${getSubtitleName(x)} (${x.codec})` */}
{/* } */}
{/* selected={selectedSubtitle === x} */}
{/* disabled={!x.link} */}
{/* onSelect={() => setSubtitle(x)} */}
{/* /> */}
{/* ))} */}
</Menu>
);
};
export const AudioMenu = (props: Partial<MenuProps>) => {
const { t } = useTranslation();
return (
<Menu
Trigger={IconButton}
icon={MusicNote}
{...tooltip(t("player.audios"), true)}
{...props}
></Menu>
);
};
export const QualityMenu = (props: Partial<MenuProps>) => {
const { t } = useTranslation();
return (
<Menu
Trigger={IconButton}
icon={SettingsIcon}
{...tooltip(t("player.quality"), true)}
{...props}
></Menu>
);
};

View File

@ -6,10 +6,6 @@ import {
touchOnly,
ts,
} from "@kyoo/primitives";
import Pause from "@material-symbols/svg-400/rounded/pause-fill.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
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 { useAtom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
@ -17,55 +13,6 @@ import { px, type Stylable, useYoshiki } from "yoshiki/native";
import { HoverTouch, hoverAtom } from "../controls";
import { playAtom } from "./state";
export const LeftButtons = ({
previousSlug,
nextSlug,
}: {
previousSlug?: string | null;
nextSlug?: string | null;
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const [isPlaying, setPlay] = useAtom(playAtom);
const spacing = css({ marginHorizontal: ts(1) });
return (
<View {...css({ flexDirection: "row" })}>
<View {...css({ flexDirection: "row" }, noTouch)}>
{previousSlug && (
<IconButton
icon={SkipPrevious}
as={Link}
href={previousSlug}
replace
{...tooltip(t("player.previous"), true)}
{...spacing}
/>
)}
<IconButton
icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
{...spacing}
/>
{nextSlug && (
<IconButton
icon={SkipNext}
as={Link}
href={nextSlug}
replace
{...tooltip(t("player.next"), true)}
{...spacing}
/>
)}
{Platform.OS === "web" && <VolumeSlider />}
</View>
<ProgressText {...css({ marginLeft: ts(1) })} />
</View>
);
};
export const TouchControls = ({
previousSlug,
nextSlug,

View File

@ -1,114 +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 { Audio, Subtitle } from "@kyoo/models";
import { IconButton, Menu, tooltip, ts } from "@kyoo/primitives";
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg";
import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { type Stylable, useYoshiki } from "yoshiki/native";
import { useSubtitleName } from "../../../../packages/ui/src/utils";
import { fullscreenAtom, subtitleAtom } from "./state";
import { AudiosMenu, QualitiesMenu } from "./video";
export const RightButtons = ({
audios,
subtitles,
fonts,
onMenuOpen,
onMenuClose,
...props
}: {
audios?: Audio[];
subtitles?: Subtitle[];
fonts?: string[];
onMenuOpen: () => void;
onMenuClose: () => void;
} & Stylable) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const getSubtitleName = useSubtitleName();
const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
const [selectedSubtitle, setSubtitle] = useAtom(subtitleAtom);
const spacing = css({ marginHorizontal: ts(1) });
return (
<View {...css({ flexDirection: "row" }, props)}>
{subtitles && subtitles.length > 0 && (
<Menu
Trigger={IconButton}
icon={ClosedCaption}
onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose}
{...tooltip(t("player.subtitles"), true)}
{...spacing}
>
<Menu.Item
label={t("player.subtitle-none")}
selected={!selectedSubtitle}
onSelect={() => setSubtitle(null)}
/>
{subtitles
.filter((x) => !!x.link)
.map((x, i) => (
<Menu.Item
key={x.index ?? i}
label={x.link ? getSubtitleName(x) : `${getSubtitleName(x)} (${x.codec})`}
selected={selectedSubtitle === x}
disabled={!x.link}
onSelect={() => setSubtitle(x)}
/>
))}
</Menu>
)}
<AudiosMenu
Trigger={IconButton}
icon={MusicNote}
onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose}
audios={audios}
{...tooltip(t("player.audios"), true)}
{...spacing}
/>
<QualitiesMenu
Trigger={IconButton}
icon={SettingsIcon}
onMenuOpen={onMenuOpen}
onMenuClose={onMenuClose}
{...tooltip(t("player.quality"), true)}
{...spacing}
/>
{Platform.OS === "web" && (
<IconButton
icon={isFullscreen ? FullscreenExit : Fullscreen}
onPress={() => setFullscreen(!isFullscreen)}
{...tooltip(t("player.fullscreen"), true)}
{...spacing}
/>
)}
</View>
);
};