Add volume slider (keyboard accessible)

Fix tooltip position on the bottom of the screen.
Add fullscreen support on the web
This commit is contained in:
Zoe Roux 2022-12-22 14:35:12 +09:00
parent b1b8772717
commit 7a1bde1b73
8 changed files with 94 additions and 79 deletions

View File

@ -64,6 +64,7 @@ export const IconButton = <AsProps = PressableProps,>({
return ( return (
<Container <Container
accessibilityRole="button"
{...(css( {...(css(
{ {
p: ts(1), p: ts(1),

View File

@ -20,8 +20,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { GestureResponderEvent, Platform, View } from "react-native"; import { GestureResponderEvent, Platform, View } from "react-native";
import { percent, Stylable, useYoshiki } from "yoshiki/native"; import { px, percent, Stylable, useYoshiki } from "yoshiki/native";
import { ts } from "./utils";
export const Slider = ({ export const Slider = ({
progress, progress,
@ -31,6 +30,7 @@ export const Slider = ({
setProgress, setProgress,
startSeek, startSeek,
endSeek, endSeek,
size = 6,
...props ...props
}: { }: {
progress: number; progress: number;
@ -40,6 +40,7 @@ export const Slider = ({
setProgress: (progress: number) => void; setProgress: (progress: number) => void;
startSeek?: () => void; startSeek?: () => void;
endSeek?: () => void; endSeek?: () => void;
size?: number;
} & Stylable) => { } & Stylable) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const ref = useRef<View>(null); const ref = useRef<View>(null);
@ -49,6 +50,8 @@ export const Slider = ({
const [isFocus, setFocus] = useState(false); const [isFocus, setFocus] = useState(false);
const smallBar = !(isSeeking || isHover || isFocus); const smallBar = !(isSeeking || isHover || isFocus);
const ts = (value: number) => px(value * size);
const change = (event: GestureResponderEvent) => { const change = (event: GestureResponderEvent) => {
event.preventDefault(); event.preventDefault();
const locationX = Platform.select({ const locationX = Platform.select({
@ -58,7 +61,6 @@ export const Slider = ({
setProgress(Math.max(0, Math.min(locationX / layout.width, 1)) * max); setProgress(Math.max(0, Math.min(locationX / layout.width, 1)) * max);
}; };
// TODO keyboard handling (left, right, up, down)
return ( return (
<View <View
ref={ref} ref={ref}
@ -66,8 +68,7 @@ export const Slider = ({
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
// @ts-ignore Web only // @ts-ignore Web only
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
// TODO: This does not work focusable
tabindex={0}
onFocus={() => setFocus(true)} onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)} onBlur={() => setFocus(false)}
onStartShouldSetResponder={() => true} onStartShouldSetResponder={() => true}
@ -84,6 +85,22 @@ export const Slider = ({
onLayout={() => onLayout={() =>
ref.current?.measure((_, __, width, ___, pageX) => setLayout({ width: width, x: pageX })) ref.current?.measure((_, __, width, ___, pageX) => setLayout({ width: width, x: pageX }))
} }
onKeyDown={(e: KeyboardEvent) => {
switch (e.code) {
case "ArrowLeft":
setProgress(Math.max(progress - 0.05 * max, 0));
break;
case "ArrowRight":
setProgress(Math.min(progress + 0.05 * max, max));
break;
case "ArrowDown":
setProgress(Math.max(progress - 0.1 * max, 0));
break;
case "ArrowUp":
setProgress(Math.min(progress + 0.1 * max, max));
break;
}
}}
{...css( {...css(
{ {
paddingVertical: ts(1), paddingVertical: ts(1),
@ -155,11 +172,12 @@ export const Slider = ({
position: "absolute", position: "absolute",
top: 0, top: 0,
bottom: 0, bottom: 0,
marginY: ts(0.5), marginY: ts(Platform.OS === "android" ? -0.5 : 0.5),
bg: (theme) => theme.accent, bg: (theme) => theme.accent,
width: ts(2), width: ts(2),
height: ts(2), height: ts(2),
borderRadius: ts(1), borderRadius: ts(1),
marginLeft: ts(-1),
}, },
smallBar && { opacity: 0 }, smallBar && { opacity: 0 },
], ],

View File

@ -18,13 +18,13 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { ToastAndroid, Platform } from "react-native"; import { ToastAndroid, Platform, ViewProps, PressableProps } from "react-native";
import { Theme } from "yoshiki/native"; import { Theme } from "yoshiki/native";
export const tooltip = (tooltip: string) => export const tooltip = (tooltip: string, up?: boolean) =>
Platform.select({ Platform.select({
web: { web: {
dataSet: { tooltip, label: tooltip }, dataSet: { tooltip, label: tooltip, tooltipPos: up ? "up" : undefined },
}, },
android: { android: {
onLongPress: () => { onLongPress: () => {
@ -66,6 +66,11 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => {
visibility: hidden; visibility: hidden;
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
} }
[data-tooltip-pos]::after {
top: unset;
bottom: 100%;
margin-bottom: 8px;
}
:where(body:not(.noHover)) [data-tooltip]:hover::after, :where(body:not(.noHover)) [data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after { [data-tooltip]:focus-visible::after {
@ -81,7 +86,6 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => {
outline: none; outline: none;
transition: box-shadow 0.15s ease-in-out; transition: box-shadow 0.15s ease-in-out;
box-shadow: 0 0 0 2px ${theme.colors.black}; box-shadow: 0 0 0 2px ${theme.colors.black};
/* box-shadow: ${theme.accent} 1px; */
} }
`}</style> `}</style>
); );

View File

@ -26,16 +26,14 @@ import { PersonAvatar } from "./person";
export const Staff = ({ slug }: { slug: string }) => { export const Staff = ({ slug }: { slug: string }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// TODO: handle infinite scroll
return ( return (
<InfiniteFetch <InfiniteFetch
query={Staff.query(slug)} query={Staff.query(slug)}
horizontal horizontal
layout={{ numColumns: 1, size: PersonAvatar.width }} layout={{ numColumns: 1, size: PersonAvatar.width }}
empty={t("show.staff-none")}
placeholderCount={20} placeholderCount={20}
> >
{/* <HorizontalList title={t("show.staff")} noContent={t("show.staff-none")}> */}
{(item, key) => ( {(item, key) => (
<PersonAvatar <PersonAvatar
key={key} key={key}

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { IconButton, Link, P, tooltip, ts } from "@kyoo/primitives"; import { IconButton, Link, P, Slider, tooltip, ts } from "@kyoo/primitives";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
@ -31,7 +31,7 @@ import VolumeMute from "@material-symbols/svg-400/rounded/volume_mute-fill.svg";
import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg"; import VolumeDown from "@material-symbols/svg-400/rounded/volume_down-fill.svg";
import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg"; import VolumeUp from "@material-symbols/svg-400/rounded/volume_up-fill.svg";
import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state";
import { useYoshiki } from "yoshiki/native"; import { px, useYoshiki } from "yoshiki/native";
export const LeftButtons = ({ export const LeftButtons = ({
previousSlug, previousSlug,
@ -53,14 +53,14 @@ export const LeftButtons = ({
icon={SkipPrevious} icon={SkipPrevious}
as={Link} as={Link}
href={previousSlug} href={previousSlug}
{...tooltip(t("player.previous"))} {...tooltip(t("player.previous"), true)}
{...spacing} {...spacing}
/> />
)} )}
<IconButton <IconButton
icon={isPlaying ? Pause : PlayArrow} icon={isPlaying ? Pause : PlayArrow}
onPress={() => setPlay(!isPlaying)} onPress={() => setPlay(!isPlaying)}
{...tooltip(isPlaying ? t("player.pause") : t("player.play"))} {...tooltip(isPlaying ? t("player.pause") : t("player.play"), true)}
{...spacing} {...spacing}
/> />
{nextSlug && ( {nextSlug && (
@ -68,7 +68,7 @@ export const LeftButtons = ({
icon={SkipNext} icon={SkipNext}
as={Link} as={Link}
href={nextSlug} href={nextSlug}
{...tooltip(t("player.next"))} {...tooltip(t("player.next"), true)}
{...spacing} {...spacing}
/> />
)} )}
@ -84,13 +84,13 @@ const VolumeSlider = () => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
return null;
return ( return (
<View <View
{...css({ {...css({
display: { xs: "none", sm: "flex" }, display: { xs: "none", sm: "flex" },
p: ts(1), alignItems: "center",
"body.hoverEnabled &:hover .slider": { width: "100px", px: "16px" }, flexDirection: "row",
paddingRight: ts(1),
})} })}
> >
<IconButton <IconButton
@ -103,26 +103,16 @@ const VolumeSlider = () => {
? VolumeDown ? VolumeDown
: VolumeUp : VolumeUp
} }
onClick={() => setMuted(!isMuted)} onPress={() => setMuted(!isMuted)}
{...tooltip(t("mute"))} {...tooltip(t("player.mute"), true)}
/>
<Slider
progress={volume}
setProgress={setVolume}
size={4}
{...css({ width: px(100) })}
{...tooltip(t("player.volume"), true)}
/> />
<View
className="slider"
sx={{
width: 0,
transition: "width .2s cubic-bezier(0.4,0, 1, 1), padding .2s cubic-bezier(0.4,0, 1, 1)",
overflow: "hidden",
alignSelf: "center",
}}
>
<Slider
value={volume}
onChange={(_, value) => setVolume(value as number)}
size="small"
aria-label={t("volume")}
sx={{ alignSelf: "center" }}
/>
</View>
</View> </View>
); );
}; };

View File

@ -23,7 +23,7 @@ import { IconButton, tooltip } from "@kyoo/primitives";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import { useState } from "react"; import { useState } from "react";
import { View } from "react-native"; import { Platform, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-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 FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
@ -72,12 +72,13 @@ export const RightButtons = ({
{/* </IconButton> */} {/* </IconButton> */}
{/* </Tooltip> */} {/* </Tooltip> */}
{/* )} */} {/* )} */}
<IconButton {Platform.OS === "web" && (
icon={isFullscreen ? FullscreenExit : Fullscreen} <IconButton
onClick={() => setFullscreen(!isFullscreen)} icon={isFullscreen ? FullscreenExit : Fullscreen}
{...tooltip(t("player.fullscreen"))} onPress={() => setFullscreen(!isFullscreen)}
sx={{ color: "white" }} {...tooltip(t("player.fullscreen"), true)}
/> />
)}
{/* {subtitleAnchor && ( */} {/* {subtitleAnchor && ( */}
{/* <SubtitleMenu */} {/* <SubtitleMenu */}
{/* subtitles={subtitles!} */} {/* subtitles={subtitles!} */}

View File

@ -33,10 +33,6 @@ import { MediaSessionManager } from "./media-session";
import { ErrorView } from "../fetch"; import { ErrorView } from "../fetch";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
let mouseCallback: NodeJS.Timeout;
const query = (slug: string): QueryIdentifier<WatchItem> => ({ const query = (slug: string): QueryIdentifier<WatchItem> => ({
path: ["watch", slug], path: ["watch", slug],
parser: WatchItemP, parser: WatchItemP,
@ -62,6 +58,14 @@ const mapData = (
}; };
}; };
// Callback used to hide the controls when the mouse goes iddle. This is stored globally to clear the old timeout
// if the mouse moves again (if this is stored as a state, the whole page is redrawn on mouse move)
let mouseCallback: NodeJS.Timeout;
// Number of time the video has been pressed. Used to handle double click. Since there is only one player,
// this can be global and not in the state.
let touchCount = 0;
let touchTimeout: NodeJS.Timeout;
export const Player: QueryPage<{ slug: string }> = ({ slug }) => { export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
@ -80,7 +84,7 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
// useVideoKeyboard(data?.subtitles, data?.fonts, previous, next); // useVideoKeyboard(data?.subtitles, data?.fonts, previous, next);
const router = useRouter(); const router = useRouter();
const setFullscreen = useSetAtom(fullscreenAtom); const [isFullscreen, setFullscreen] = useAtom(fullscreenAtom);
const [isPlaying, setPlay] = useAtom(playAtom); const [isPlaying, setPlay] = useAtom(playAtom);
const [showHover, setHover] = useState(false); const [showHover, setHover] = useState(false);
const [mouseMoved, setMouseMoved] = useState(false); const [mouseMoved, setMouseMoved] = useState(false);
@ -154,7 +158,23 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
{/* `}</style> */} {/* `}</style> */}
<Pressable <Pressable
onHoverOut={() => setMouseMoved(false)} onHoverOut={() => setMouseMoved(false)}
onPress={Platform.OS === "web" ? () => setPlay(!isPlaying) : show} onPress={
Platform.OS === "web"
? (e) => {
e.preventDefault();
touchCount++;
if (touchCount == 2) {
touchCount = 0;
setFullscreen(!isFullscreen);
clearTimeout(touchTimeout);
} else
touchTimeout = setTimeout(() => {
touchCount = 0;
}, 400);
setPlay(!isPlaying);
}
: show
}
{...css({ {...css({
flexGrow: 1, flexGrow: 1,
// @ts-ignore // @ts-ignore

View File

@ -36,6 +36,7 @@ const playModeAtom = atom<PlayMode>(PlayMode.Direct);
export const playAtom = atom(true); export const playAtom = atom(true);
export const loadAtom = atom(false); export const loadAtom = atom(false);
export const bufferedAtom = atom(0); export const bufferedAtom = atom(0);
export const durationAtom = atom<number | undefined>(undefined); export const durationAtom = atom<number | undefined>(undefined);
@ -49,18 +50,9 @@ export const progressAtom = atom<number, number>(
const privateProgressAtom = atom(0); const privateProgressAtom = atom(0);
const publicProgressAtom = atom(0); const publicProgressAtom = atom(0);
export const [_volumeAtom, volumeAtom] = bakedAtom(100, (get, set, value, baker) => { export const volumeAtom = atom(100);
const player = get(playerAtom); export const mutedAtom = atom(false);
if (!player?.current) return;
set(baker, value);
if (player.current) player.current.volume = Math.max(0, Math.min(value, 100)) / 100;
});
export const [_mutedAtom, mutedAtom] = bakedAtom(false, (get, set, value, baker) => {
const player = get(playerAtom);
if (!player?.current) return;
set(baker, value);
if (player.current) player.current.muted = value;
});
export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker) => { export const [_, fullscreenAtom] = bakedAtom(false, async (_, set, value, baker) => {
try { try {
if (value) { if (value) {
@ -82,11 +74,6 @@ export const Video = ({
setError, setError,
...props ...props
}: { links?: WatchItem["link"]; setError: (error: string | undefined) => void } & VideoProps) => { }: { links?: WatchItem["link"]; setError: (error: string | undefined) => void } & VideoProps) => {
// const player = useRef<HTMLVideoElement>(null);
// const setPlayer = useSetAtom(playerAtom);
// const setVolume = useSetAtom(_volumeAtom);
// const setMuted = useSetAtom(_mutedAtom);
// const setFullscreen = useSetAtom(fullscreenAtom);
// const [playMode, setPlayMode] = useAtom(playModeAtom); // const [playMode, setPlayMode] = useAtom(playModeAtom);
const ref = useRef<NativeVideo | null>(null); const ref = useRef<NativeVideo | null>(null);
@ -101,17 +88,12 @@ export const Video = ({
const setPrivateProgress = useSetAtom(privateProgressAtom); const setPrivateProgress = useSetAtom(privateProgressAtom);
const setBuffered = useSetAtom(bufferedAtom); const setBuffered = useSetAtom(bufferedAtom);
const setDuration = useSetAtom(durationAtom); const setDuration = useSetAtom(durationAtom);
useEffect(() => { useEffect(() => {
ref.current?.setStatusAsync({ positionMillis: publicProgress }); ref.current?.setStatusAsync({ positionMillis: publicProgress });
}, [publicProgress]); }, [publicProgress]);
// setPlayer(player); const volume = useAtomValue(volumeAtom);
const isMuted = useAtomValue(mutedAtom);
// useEffect(() => {
// if (!player.current) return;
// setPlay(!player.current.paused);
// }, [setPlay]);
// useEffect(() => { // useEffect(() => {
// setPlayMode(PlayMode.Direct); // setPlayMode(PlayMode.Direct);
@ -150,6 +132,8 @@ export const Video = ({
{...props} {...props}
source={links ? { uri: links.direct } : undefined} source={links ? { uri: links.direct } : undefined}
shouldPlay={isPlaying} shouldPlay={isPlaying}
isMuted={isMuted}
volume={volume}
onPlaybackStatusUpdate={(status) => { onPlaybackStatusUpdate={(status) => {
if (!status.isLoaded) { if (!status.isLoaded) {
setLoad(true); setLoad(true);
@ -158,7 +142,6 @@ export const Video = ({
} }
setLoad(status.isPlaying !== status.shouldPlay); setLoad(status.isPlaying !== status.shouldPlay);
setPlay(status.shouldPlay);
setPrivateProgress(status.positionMillis); setPrivateProgress(status.positionMillis);
setBuffered(status.playableDurationMillis ?? 0); setBuffered(status.playableDurationMillis ?? 0);
setDuration(status.durationMillis); setDuration(status.durationMillis);