mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-10-31 10:37:13 -04:00 
			
		
		
		
	Rewrite player's controls compenents
This commit is contained in:
		
							parent
							
								
									a310ceaed5
								
							
						
					
					
						commit
						fc9695a2dc
					
				| @ -66,8 +66,12 @@ export const expo: ExpoConfig = { | ||||
| 		[ | ||||
| 			"react-native-video", | ||||
| 			{ | ||||
| 				enableNotificationControls: true, | ||||
| 				enableAndroidPictureInPicture: true, | ||||
| 				enableBackgroundAudio: true, | ||||
| 				androidExtensions: { | ||||
| 					useExoplayerDash: true, | ||||
| 					useExoplayerHls: true, | ||||
| 				}, | ||||
| 			}, | ||||
| 		], | ||||
| 	], | ||||
|  | ||||
| @ -1,482 +0,0 @@ | ||||
| import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; | ||||
| 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, | ||||
| 	ContrastArea, | ||||
| 	H1, | ||||
| 	H2, | ||||
| 	IconButton, | ||||
| 	Poster, | ||||
| 	PressableFeedback, | ||||
| 	Skeleton, | ||||
| 	Slider, | ||||
| 	Tooltip, | ||||
| 	tooltip, | ||||
| 	ts, | ||||
| 	useIsTouch, | ||||
| } from "~/primitives"; | ||||
| import { LeftButtons, TouchControls } from "./left-buttons"; | ||||
| import { RightButtons } from "./right-buttons"; | ||||
| import { BottomScrubber, ScrubberTooltip } from "./scrubber"; | ||||
| 
 | ||||
| export const Hover = ({ | ||||
| 	isLoading, | ||||
| 	url, | ||||
| 	name, | ||||
| 	showName, | ||||
| 	poster, | ||||
| 	chapters, | ||||
| 	subtitles, | ||||
| 	audios, | ||||
| 	fonts, | ||||
| 	previousSlug, | ||||
| 	nextSlug, | ||||
| }: { | ||||
| 	isLoading: boolean; | ||||
| 	url: string; | ||||
| 	name?: string | null; | ||||
| 	showName?: string; | ||||
| 	poster?: KyooImage | null; | ||||
| 	chapters?: Chapter[]; | ||||
| 	subtitles?: Subtitle[]; | ||||
| 	audios?: Audio[]; | ||||
| 	fonts?: string[]; | ||||
| 	previousSlug?: string | null; | ||||
| 	nextSlug?: string | null; | ||||
| }) => { | ||||
| 	const show = useAtomValue(hoverAtom); | ||||
| 	const setHover = useSetAtom(hoverReasonAtom); | ||||
| 	const isSeeking = useAtomValue(seekingAtom); | ||||
| 	const isTouch = useIsTouch(); | ||||
| 
 | ||||
| 	const showBottomSeeker = isSeeking && isTouch; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<ContrastArea mode="dark"> | ||||
| 			{({ css }) => ( | ||||
| 				<> | ||||
| 					<TouchControls previousSlug={previousSlug} nextSlug={nextSlug} /> | ||||
| 					<View | ||||
| 						onPointerEnter={(e) => { | ||||
| 							if (e.nativeEvent.pointerType === "mouse") | ||||
| 								setHover((x) => ({ ...x, mouseHover: true })); | ||||
| 						}} | ||||
| 						onPointerLeave={(e) => { | ||||
| 							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 | ||||
| 							isLoading={isLoading} | ||||
| 							name={showName} | ||||
| 							{...css({ | ||||
| 								pointerEvents: "auto", | ||||
| 							})} | ||||
| 						/> | ||||
| 						<View | ||||
| 							{...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", | ||||
| 								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> | ||||
| 				</> | ||||
| 			)} | ||||
| 		</ContrastArea> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => { | ||||
| 	const hover = useAtomValue(hoverAtom); | ||||
| 	const setHover = useSetAtom(hoverReasonAtom); | ||||
| 	const mouseCallback = useRef<NodeJS.Timeout | null>(null); | ||||
| 	const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ | ||||
| 		count: 0, | ||||
| 	}); | ||||
| 	const playerWidth = useRef<number | null>(null); | ||||
| 	const isTouch = useIsTouch(); | ||||
| 
 | ||||
| 	const show = useCallback(() => { | ||||
| 		setHover((x) => ({ ...x, mouseMoved: true })); | ||||
| 		if (mouseCallback.current) clearTimeout(mouseCallback.current); | ||||
| 		mouseCallback.current = setTimeout(() => { | ||||
| 			setHover((x) => ({ ...x, mouseMoved: false })); | ||||
| 		}, 2500); | ||||
| 	}, [setHover]); | ||||
| 
 | ||||
| 	// On mouse move
 | ||||
| 	useEffect(() => { | ||||
| 		if (Platform.OS !== "web") return; | ||||
| 		const handler = (e: PointerEvent) => { | ||||
| 			if (e.pointerType !== "mouse") return; | ||||
| 			show(); | ||||
| 		}; | ||||
| 
 | ||||
| 		document.addEventListener("pointermove", handler); | ||||
| 		return () => document.removeEventListener("pointermove", handler); | ||||
| 	}, [show]); | ||||
| 
 | ||||
| 	// When the controls hide, remove focus so space can be used to play/pause instead of triggering the button
 | ||||
| 	// It also serves to hide the tooltip.
 | ||||
| 	useEffect(() => { | ||||
| 		if (Platform.OS !== "web") return; | ||||
| 		if (!hover && document.activeElement instanceof HTMLElement) | ||||
| 			document.activeElement.blur(); | ||||
| 	}, [hover]); | ||||
| 
 | ||||
| 	const { css } = useYoshiki(); | ||||
| 
 | ||||
| 	const duration = useAtomValue(durationAtom); | ||||
| 	const setPlay = useSetAtom(playAtom); | ||||
| 	const setProgress = useSetAtom(progressAtom); | ||||
| 	const setFullscreen = useSetAtom(fullscreenAtom); | ||||
| 
 | ||||
| 	const onPress = (e: { pointerType: string; x: number }) => { | ||||
| 		if (Platform.OS === "web" && e.pointerType === "mouse") { | ||||
| 			setPlay((x) => !x); | ||||
| 			return; | ||||
| 		} | ||||
| 		if (hover) setHover((x) => ({ ...x, mouseMoved: false })); | ||||
| 		else show(); | ||||
| 	}; | ||||
| 	const onDoublePress = (e: { pointerType: string; x: number }) => { | ||||
| 		if (Platform.OS === "web" && e.pointerType === "mouse") { | ||||
| 			// Only reset touch count for the web, on mobile you can continue to seek by pressing again.
 | ||||
| 			touch.current.count = 0; | ||||
| 			setFullscreen((x) => !x); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		show(); | ||||
| 		if (!duration || !playerWidth.current) return; | ||||
| 
 | ||||
| 		if (e.x < playerWidth.current * 0.33) { | ||||
| 			setProgress((x) => Math.max(x - 10, 0)); | ||||
| 		} | ||||
| 		if (e.x > playerWidth.current * 0.66) { | ||||
| 			setProgress((x) => Math.min(x + 10, duration)); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const onAnyPress = (e: { pointerType: string; x: number }) => { | ||||
| 		touch.current.count++; | ||||
| 		if (touch.current.count >= 2) { | ||||
| 			onDoublePress(e); | ||||
| 			clearTimeout(touch.current.timeout); | ||||
| 		} else { | ||||
| 			onPress(e); | ||||
| 		} | ||||
| 
 | ||||
| 		touch.current.timeout = setTimeout(() => { | ||||
| 			touch.current.count = 0; | ||||
| 			touch.current.timeout = undefined; | ||||
| 		}, 400); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<Pressable | ||||
| 			tabIndex={-1} | ||||
| 			onPointerLeave={(e) => { | ||||
| 				if (e.nativeEvent.pointerType === "mouse") | ||||
| 					setHover((x) => ({ ...x, mouseMoved: false })); | ||||
| 			}} | ||||
| 			onPress={(e) => { | ||||
| 				e.preventDefault(); | ||||
| 				onAnyPress({ | ||||
| 					pointerType: isTouch ? "touch" : "mouse", | ||||
| 					x: e.nativeEvent.locationX ?? e.nativeEvent.pageX, | ||||
| 				}); | ||||
| 			}} | ||||
| 			onLayout={(e) => { | ||||
| 				playerWidth.current = e.nativeEvent.layout.width; | ||||
| 			}} | ||||
| 			{...css( | ||||
| 				// @ts-expect-error Web only property (cursor: unset)
 | ||||
| 				{ | ||||
| 					flexDirection: "row", | ||||
| 					justifyContent: "center", | ||||
| 					alignItems: "center", | ||||
| 					position: "absolute", | ||||
| 					top: 0, | ||||
| 					left: 0, | ||||
| 					right: 0, | ||||
| 					bottom: 0, | ||||
| 					cursor: hover ? "unset" : "none", | ||||
| 				}, | ||||
| 				props, | ||||
| 			)} | ||||
| 		> | ||||
| 			{children} | ||||
| 		</Pressable> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| const ProgressBar = ({ | ||||
| 	url, | ||||
| 	chapters, | ||||
| }: { | ||||
| 	url: string; | ||||
| 	chapters?: Chapter[]; | ||||
| }) => { | ||||
| 	const [progress, setProgress] = useAtom(progressAtom); | ||||
| 	const buffered = useAtomValue(bufferedAtom); | ||||
| 	const duration = useAtomValue(durationAtom); | ||||
| 	const setPlay = useSetAtom(playAtom); | ||||
| 	const [hoverProgress, setHoverProgress] = useState<number | null>(null); | ||||
| 	const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 }); | ||||
| 	const [seekProgress, setSeekProgress] = useAtom(seekProgressAtom); | ||||
| 	const setSeeking = useSetAtom(seekingAtom); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<Slider | ||||
| 				progress={seekProgress ?? progress} | ||||
| 				startSeek={() => { | ||||
| 					setPlay(false); | ||||
| 					setSeeking(true); | ||||
| 				}} | ||||
| 				endSeek={() => { | ||||
| 					setSeeking(false); | ||||
| 					setProgress(seekProgress!); | ||||
| 					setSeekProgress(null); | ||||
| 					setTimeout(() => setPlay(true), 10); | ||||
| 				}} | ||||
| 				onHover={(progress, layout) => { | ||||
| 					setHoverProgress(progress); | ||||
| 					setLayout(layout); | ||||
| 				}} | ||||
| 				setProgress={(progress) => setSeekProgress(progress)} | ||||
| 				subtleProgress={buffered} | ||||
| 				max={duration} | ||||
| 				markers={chapters?.map((x) => x.startTime)} | ||||
| 				dataSet={{ tooltipId: "progress-scrubber" }} | ||||
| 			/> | ||||
| 			<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 }} | ||||
| 			/> | ||||
| 		</> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const Back = ({ | ||||
| 	isLoading, | ||||
| 	name, | ||||
| 	...props | ||||
| }: { isLoading: boolean; name?: string } & 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> | ||||
| 				{isLoading ? ( | ||||
| 					<Skeleton {...css({ width: rem(5) })} /> | ||||
| 				) : ( | ||||
| 					<H1 | ||||
| 						{...css({ | ||||
| 							alignSelf: "center", | ||||
| 							fontSize: rem(1.5), | ||||
| 							marginLeft: rem(1), | ||||
| 						})} | ||||
| 					> | ||||
| 						{name} | ||||
| 					</H1> | ||||
| 				)} | ||||
| 			</Skeleton> | ||||
| 		</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> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const [isLoading, setLoading] = useState(false); | ||||
| 
 | ||||
| 	useEvent(player, "onStatusChange", (status) => { | ||||
| 		setLoading(status === "loading"); | ||||
| 	}); | ||||
| 
 | ||||
| 	if (!isLoading) return null; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<View | ||||
| 			{...css({ | ||||
| 				position: "absolute", | ||||
| 				pointerEvents: "none", | ||||
| 				top: 0, | ||||
| 				bottom: 0, | ||||
| 				left: 0, | ||||
| 				right: 0, | ||||
| 				bg: (theme) => alpha(theme.colors.black, 0.3), | ||||
| 				justifyContent: "center", | ||||
| 			})} | ||||
| 		> | ||||
| 			<CircularProgress {...css({ alignSelf: "center" })} /> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
| @ -1,221 +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 { IconButton, Link, P, Slider, noTouch, tooltip, 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 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 { useAtom, useAtomValue } from "jotai"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import { Platform, View } from "react-native"; | ||||
| import { type Stylable, px, useYoshiki } from "yoshiki/native"; | ||||
| import { durationAtom, mutedAtom, playAtom, progressAtom, volumeAtom } from "../state"; | ||||
| import { HoverTouch, hoverAtom } from "./hover"; | ||||
| 
 | ||||
| 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, | ||||
| 	...props | ||||
| }: { | ||||
| 	previousSlug?: string | null; | ||||
| 	nextSlug?: string | null; | ||||
| }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const [isPlaying, setPlay] = useAtom(playAtom); | ||||
| 	const hover = useAtomValue(hoverAtom); | ||||
| 
 | ||||
| 	const common = css( | ||||
| 		[ | ||||
| 			{ | ||||
| 				backgroundColor: (theme) => theme.darkOverlay, | ||||
| 				marginHorizontal: ts(3), | ||||
| 			}, | ||||
| 		], | ||||
| 		touchOnly, | ||||
| 	); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<HoverTouch | ||||
| 			{...css( | ||||
| 				{ | ||||
| 					flexDirection: "row", | ||||
| 					justifyContent: "center", | ||||
| 					alignItems: "center", | ||||
| 					position: "absolute", | ||||
| 					top: 0, | ||||
| 					left: 0, | ||||
| 					right: 0, | ||||
| 					bottom: 0, | ||||
| 				}, | ||||
| 				props, | ||||
| 			)} | ||||
| 		> | ||||
| 			{hover && ( | ||||
| 				<> | ||||
| 					<IconButton | ||||
| 						icon={SkipPrevious} | ||||
| 						as={Link} | ||||
| 						href={previousSlug!} | ||||
| 						replace | ||||
| 						size={ts(4)} | ||||
| 						{...css([!previousSlug && { opacity: 0, pointerEvents: "none" }], common)} | ||||
| 					/> | ||||
| 					<IconButton | ||||
| 						icon={isPlaying ? Pause : PlayArrow} | ||||
| 						onPress={() => setPlay(!isPlaying)} | ||||
| 						size={ts(8)} | ||||
| 						{...common} | ||||
| 					/> | ||||
| 					<IconButton | ||||
| 						icon={SkipNext} | ||||
| 						as={Link} | ||||
| 						href={nextSlug!} | ||||
| 						replace | ||||
| 						size={ts(4)} | ||||
| 						{...css([!nextSlug && { opacity: 0, pointerEvents: "none" }], common)} | ||||
| 					/> | ||||
| 				</> | ||||
| 			)} | ||||
| 		</HoverTouch> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| const VolumeSlider = () => { | ||||
| 	const [volume, setVolume] = useAtom(volumeAtom); | ||||
| 	const [isMuted, setMuted] = useAtom(mutedAtom); | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const { t } = useTranslation(); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<View | ||||
| 			{...css({ | ||||
| 				display: { xs: "none", sm: "flex" }, | ||||
| 				alignItems: "center", | ||||
| 				flexDirection: "row", | ||||
| 				paddingRight: ts(1), | ||||
| 			})} | ||||
| 		> | ||||
| 			<IconButton | ||||
| 				icon={ | ||||
| 					isMuted || volume === 0 | ||||
| 						? VolumeOff | ||||
| 						: volume < 25 | ||||
| 							? VolumeMute | ||||
| 							: volume < 65 | ||||
| 								? VolumeDown | ||||
| 								: VolumeUp | ||||
| 				} | ||||
| 				onPress={() => setMuted(!isMuted)} | ||||
| 				{...tooltip(t("player.mute"), true)} | ||||
| 			/> | ||||
| 			<Slider | ||||
| 				progress={volume} | ||||
| 				setProgress={setVolume} | ||||
| 				size={4} | ||||
| 				{...css({ width: px(100) })} | ||||
| 				{...tooltip(t("player.volume"), true)} | ||||
| 			/> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| const ProgressText = (props: Stylable) => { | ||||
| 	const progress = useAtomValue(progressAtom); | ||||
| 	const duration = useAtomValue(durationAtom); | ||||
| 	const { css } = useYoshiki(); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<P {...css({ alignSelf: "center" }, props)}> | ||||
| 			{toTimerString(progress, duration)} : {toTimerString(duration)} | ||||
| 		</P> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const toTimerString = (timer?: number, duration?: number) => { | ||||
| 	if (!duration) duration = timer; | ||||
| 	if ( | ||||
| 		timer === undefined || | ||||
| 		duration === undefined || | ||||
| 		Number.isNaN(duration) || | ||||
| 		Number.isNaN(timer) | ||||
| 	) | ||||
| 		return "??:??"; | ||||
| 	const h = Math.floor(timer / 3600); | ||||
| 	const min = Math.floor((timer / 60) % 60); | ||||
| 	const sec = Math.floor(timer % 60); | ||||
| 	const fmt = (n: number) => n.toString().padStart(2, "0"); | ||||
| 
 | ||||
| 	if (duration >= 3600) return `${fmt(h)}:${fmt(min)}:${fmt(sec)}`; | ||||
| 	return `${fmt(min)}:${fmt(sec)}`; | ||||
| }; | ||||
							
								
								
									
										88
									
								
								front/src/ui/player/controls/back.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								front/src/ui/player/controls/back.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,88 @@ | ||||
| import ArrowBack from "@material-symbols/svg-400/rounded/arrow_back-fill.svg"; | ||||
| import { useRouter } from "expo-router"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import { View, type ViewProps } from "react-native"; | ||||
| import { percent, rem, useYoshiki } from "yoshiki/native"; | ||||
| import { | ||||
| 	H1, | ||||
| 	IconButton, | ||||
| 	PressableFeedback, | ||||
| 	Skeleton, | ||||
| 	tooltip, | ||||
| } from "~/primitives"; | ||||
| 
 | ||||
| export const Back = ({ name, ...props }: { name: string } & 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"))} | ||||
| 			/> | ||||
| 			<H1 | ||||
| 				{...css({ | ||||
| 					alignSelf: "center", | ||||
| 					fontSize: rem(1.5), | ||||
| 					marginLeft: rem(1), | ||||
| 				})} | ||||
| 			> | ||||
| 				{name} | ||||
| 			</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) })} /> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										307
									
								
								front/src/ui/player/controls/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								front/src/ui/player/controls/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,307 @@ | ||||
| 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"; | ||||
| 
 | ||||
| export const Controls = ({ | ||||
| 	player, | ||||
| 	title, | ||||
| }: { | ||||
| 	player: VideoPlayer; | ||||
| 	title: string; | ||||
| 	description: string | null; | ||||
| 	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 | ||||
| 			// onPointerEnter={(e) => {
 | ||||
| 			// 	if (e.nativeEvent.pointerType === "mouse")
 | ||||
| 			// 		setHover((x) => ({ ...x, mouseHover: true }));
 | ||||
| 			// }}
 | ||||
| 			// onPointerLeave={(e) => {
 | ||||
| 			// 	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", | ||||
| 				})} | ||||
| 			/> | ||||
| 			<View | ||||
| 				{...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", | ||||
| 					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> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const HoverTouch = ({ children, ...props }: { children: ReactNode }) => { | ||||
| 	const hover = useAtomValue(hoverAtom); | ||||
| 	const setHover = useSetAtom(hoverReasonAtom); | ||||
| 	const mouseCallback = useRef<NodeJS.Timeout | null>(null); | ||||
| 	const touch = useRef<{ count: number; timeout?: NodeJS.Timeout }>({ | ||||
| 		count: 0, | ||||
| 	}); | ||||
| 	const playerWidth = useRef<number | null>(null); | ||||
| 	const isTouch = useIsTouch(); | ||||
| 
 | ||||
| 	const show = useCallback(() => { | ||||
| 		setHover((x) => ({ ...x, mouseMoved: true })); | ||||
| 		if (mouseCallback.current) clearTimeout(mouseCallback.current); | ||||
| 		mouseCallback.current = setTimeout(() => { | ||||
| 			setHover((x) => ({ ...x, mouseMoved: false })); | ||||
| 		}, 2500); | ||||
| 	}, [setHover]); | ||||
| 
 | ||||
| 	// On mouse move
 | ||||
| 	useEffect(() => { | ||||
| 		if (Platform.OS !== "web") return; | ||||
| 		const handler = (e: PointerEvent) => { | ||||
| 			if (e.pointerType !== "mouse") return; | ||||
| 			show(); | ||||
| 		}; | ||||
| 
 | ||||
| 		document.addEventListener("pointermove", handler); | ||||
| 		return () => document.removeEventListener("pointermove", handler); | ||||
| 	}, [show]); | ||||
| 
 | ||||
| 	// When the controls hide, remove focus so space can be used to play/pause instead of triggering the button
 | ||||
| 	// It also serves to hide the tooltip.
 | ||||
| 	useEffect(() => { | ||||
| 		if (Platform.OS !== "web") return; | ||||
| 		if (!hover && document.activeElement instanceof HTMLElement) | ||||
| 			document.activeElement.blur(); | ||||
| 	}, [hover]); | ||||
| 
 | ||||
| 	const { css } = useYoshiki(); | ||||
| 
 | ||||
| 	const duration = useAtomValue(durationAtom); | ||||
| 	const setPlay = useSetAtom(playAtom); | ||||
| 	const setProgress = useSetAtom(progressAtom); | ||||
| 	const setFullscreen = useSetAtom(fullscreenAtom); | ||||
| 
 | ||||
| 	const onPress = (e: { pointerType: string; x: number }) => { | ||||
| 		if (Platform.OS === "web" && e.pointerType === "mouse") { | ||||
| 			setPlay((x) => !x); | ||||
| 			return; | ||||
| 		} | ||||
| 		if (hover) setHover((x) => ({ ...x, mouseMoved: false })); | ||||
| 		else show(); | ||||
| 	}; | ||||
| 	const onDoublePress = (e: { pointerType: string; x: number }) => { | ||||
| 		if (Platform.OS === "web" && e.pointerType === "mouse") { | ||||
| 			// Only reset touch count for the web, on mobile you can continue to seek by pressing again.
 | ||||
| 			touch.current.count = 0; | ||||
| 			setFullscreen((x) => !x); | ||||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		show(); | ||||
| 		if (!duration || !playerWidth.current) return; | ||||
| 
 | ||||
| 		if (e.x < playerWidth.current * 0.33) { | ||||
| 			setProgress((x) => Math.max(x - 10, 0)); | ||||
| 		} | ||||
| 		if (e.x > playerWidth.current * 0.66) { | ||||
| 			setProgress((x) => Math.min(x + 10, duration)); | ||||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	const onAnyPress = (e: { pointerType: string; x: number }) => { | ||||
| 		touch.current.count++; | ||||
| 		if (touch.current.count >= 2) { | ||||
| 			onDoublePress(e); | ||||
| 			clearTimeout(touch.current.timeout); | ||||
| 		} else { | ||||
| 			onPress(e); | ||||
| 		} | ||||
| 
 | ||||
| 		touch.current.timeout = setTimeout(() => { | ||||
| 			touch.current.count = 0; | ||||
| 			touch.current.timeout = undefined; | ||||
| 		}, 400); | ||||
| 	}; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<Pressable | ||||
| 			tabIndex={-1} | ||||
| 			onPointerLeave={(e) => { | ||||
| 				if (e.nativeEvent.pointerType === "mouse") | ||||
| 					setHover((x) => ({ ...x, mouseMoved: false })); | ||||
| 			}} | ||||
| 			onPress={(e) => { | ||||
| 				e.preventDefault(); | ||||
| 				onAnyPress({ | ||||
| 					pointerType: isTouch ? "touch" : "mouse", | ||||
| 					x: e.nativeEvent.locationX ?? e.nativeEvent.pageX, | ||||
| 				}); | ||||
| 			}} | ||||
| 			onLayout={(e) => { | ||||
| 				playerWidth.current = e.nativeEvent.layout.width; | ||||
| 			}} | ||||
| 			{...css( | ||||
| 				// @ts-expect-error Web only property (cursor: unset)
 | ||||
| 				{ | ||||
| 					flexDirection: "row", | ||||
| 					justifyContent: "center", | ||||
| 					alignItems: "center", | ||||
| 					position: "absolute", | ||||
| 					top: 0, | ||||
| 					left: 0, | ||||
| 					right: 0, | ||||
| 					bottom: 0, | ||||
| 					cursor: hover ? "unset" : "none", | ||||
| 				}, | ||||
| 				props, | ||||
| 			)} | ||||
| 		> | ||||
| 			{children} | ||||
| 		</Pressable> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| 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> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										118
									
								
								front/src/ui/player/controls/misc.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								front/src/ui/player/controls/misc.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,118 @@ | ||||
| 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 { useTranslation } from "react-i18next"; | ||||
| import { View } from "react-native"; | ||||
| import { useEvent, type VideoPlayer } from "react-native-video"; | ||||
| import { px, useYoshiki } from "yoshiki/native"; | ||||
| import { | ||||
| 	alpha, | ||||
| 	CircularProgress, | ||||
| 	IconButton, | ||||
| 	Slider, | ||||
| 	tooltip, | ||||
| 	ts, | ||||
| } from "~/primitives"; | ||||
| 
 | ||||
| export const PlayButton = ({ player, ...props }: { player: VideoPlayer }) => { | ||||
| 	const { t } = useTranslation(); | ||||
| 
 | ||||
| 	const [playing, setPlay] = useState(player.isPlaying); | ||||
| 	useEvent(player, "onPlaybackStateChange", (status) => { | ||||
| 		setPlay(status.isPlaying); | ||||
| 	}); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<IconButton | ||||
| 			icon={playing ? Pause : PlayArrow} | ||||
| 			onPress={() => { | ||||
| 				if (playing) player.pause(); | ||||
| 				else player.play(); | ||||
| 			}} | ||||
| 			{...tooltip(playing ? t("player.pause") : t("player.play"), true)} | ||||
| 			{...props} | ||||
| 		/> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const VolumeSlider = ({ player, ...props }: { player: VideoPlayer }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const { t } = useTranslation(); | ||||
| 
 | ||||
| 	const [volume, setVolume] = useState(player.volume); | ||||
| 	useEvent(player, "onVolumeChange", setVolume); | ||||
| 	// TODO: listen to `player.muted` changes (currently hook does not exists
 | ||||
| 	// const [muted, setMuted] = useState(player.muted);
 | ||||
| 	const muted = player.muted; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<View | ||||
| 			{...css( | ||||
| 				{ | ||||
| 					display: { xs: "none", sm: "flex" }, | ||||
| 					alignItems: "center", | ||||
| 					flexDirection: "row", | ||||
| 					paddingRight: ts(1), | ||||
| 				}, | ||||
| 				props, | ||||
| 			)} | ||||
| 		> | ||||
| 			<IconButton | ||||
| 				icon={ | ||||
| 					muted || volume === 0 | ||||
| 						? VolumeOff | ||||
| 						: volume < 25 | ||||
| 							? VolumeMute | ||||
| 							: volume < 65 | ||||
| 								? VolumeDown | ||||
| 								: VolumeUp | ||||
| 				} | ||||
| 				onPress={() => { | ||||
| 					player.muted = !muted; | ||||
| 				}} | ||||
| 				{...tooltip(t("player.mute"), true)} | ||||
| 			/> | ||||
| 			<Slider | ||||
| 				progress={volume} | ||||
| 				setProgress={(vol) => { | ||||
| 					player.volume = vol; | ||||
| 				}} | ||||
| 				size={4} | ||||
| 				{...css({ width: px(100) })} | ||||
| 				{...tooltip(t("player.volume"), true)} | ||||
| 			/> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const LoadingIndicator = ({ player }: { player: VideoPlayer }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const [isLoading, setLoading] = useState(false); | ||||
| 
 | ||||
| 	useEvent(player, "onStatusChange", (status) => { | ||||
| 		setLoading(status === "loading"); | ||||
| 	}); | ||||
| 
 | ||||
| 	if (!isLoading) return null; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<View | ||||
| 			{...css({ | ||||
| 				position: "absolute", | ||||
| 				pointerEvents: "none", | ||||
| 				top: 0, | ||||
| 				bottom: 0, | ||||
| 				left: 0, | ||||
| 				right: 0, | ||||
| 				bg: (theme) => alpha(theme.colors.black, 0.3), | ||||
| 				justifyContent: "center", | ||||
| 			})} | ||||
| 		> | ||||
| 			<CircularProgress {...css({ alignSelf: "center" })} /> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										111
									
								
								front/src/ui/player/controls/progress.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								front/src/ui/player/controls/progress.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,111 @@ | ||||
| import { useState } from "react"; | ||||
| import type { TextProps } from "react-native"; | ||||
| import { useEvent, type VideoPlayer } from "react-native-video"; | ||||
| import { useYoshiki } from "yoshiki/native"; | ||||
| import type { Chapter } from "~/models"; | ||||
| import { P, Slider } from "~/primitives"; | ||||
| 
 | ||||
| export const ProgressBar = ({ | ||||
| 	player, | ||||
| 	// url,
 | ||||
| 	chapters, | ||||
| }: { | ||||
| 	player: VideoPlayer; | ||||
| 	// url: string;
 | ||||
| 	chapters?: Chapter[]; | ||||
| }) => { | ||||
| 	const [duration, setDuration] = useState(player.duration || 100); | ||||
| 	useEvent(player, "onLoad", (info) => { | ||||
| 		if (info.duration) setDuration(info.duration); | ||||
| 	}); | ||||
| 
 | ||||
| 	const [progress, setProgress] = useState(player.currentTime || 0); | ||||
| 	const [buffer, setBuffer] = useState(0); | ||||
| 	useEvent(player, "onProgress", (progress) => { | ||||
| 		setProgress(progress.currentTime); | ||||
| 		setBuffer(progress.bufferDuration); | ||||
| 	}); | ||||
| 
 | ||||
| 	const [seek, setSeek] = useState<number | null>(null); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<Slider | ||||
| 				progress={seek ?? progress} | ||||
| 				subtleProgress={buffer} | ||||
| 				max={duration} | ||||
| 				startSeek={() => { | ||||
| 					player.pause(); | ||||
| 				}} | ||||
| 				setProgress={setSeek} | ||||
| 				endSeek={() => { | ||||
| 					setProgress(seek!); | ||||
| 					setSeek(null); | ||||
| 					setTimeout(player.play, 10); | ||||
| 				}} | ||||
| 				// onHover={(progress, layout) => {
 | ||||
| 				// 	setHoverProgress(progress);
 | ||||
| 				// 	setLayout(layout);
 | ||||
| 				// }}
 | ||||
| 				markers={chapters?.map((x) => x.startTime)} | ||||
| 				// dataSet={{ tooltipId: "progress-scrubber" }}
 | ||||
| 			/> | ||||
| 			{/* <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 }} */} | ||||
| 			{/* /> */} | ||||
| 		</> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export const ProgressText = ({ | ||||
| 	player, | ||||
| 	...props | ||||
| }: { player: VideoPlayer } & TextProps) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 
 | ||||
| 	const [progress, setProgress] = useState(player.currentTime || 0); | ||||
| 	useEvent(player, "onProgress", (progress) => { | ||||
| 		setProgress(progress.currentTime); | ||||
| 	}); | ||||
| 	const [duration, setDuration] = useState(player.duration || 100); | ||||
| 	useEvent(player, "onLoad", (info) => { | ||||
| 		if (info.duration) setDuration(info.duration); | ||||
| 	}); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<P {...css({ alignSelf: "center" }, props)}> | ||||
| 			{toTimerString(progress, duration)} : {toTimerString(duration)} | ||||
| 		</P> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| const toTimerString = (timer?: number, duration?: number) => { | ||||
| 	if (!duration) duration = timer; | ||||
| 	if (timer === undefined || Number.isNaN(timer)) return "??:??"; | ||||
| 
 | ||||
| 	const h = Math.floor(timer / 3600); | ||||
| 	const min = Math.floor((timer / 60) % 60); | ||||
| 	const sec = Math.floor(timer % 60); | ||||
| 	const fmt = (n: number) => n.toString().padStart(2, "0"); | ||||
| 
 | ||||
| 	return h !== 0 || (duration && duration >= 3600) | ||||
| 		? `${fmt(h)}:${fmt(min)}:${fmt(sec)}` | ||||
| 		: `${fmt(min)}:${fmt(sec)}`; | ||||
| }; | ||||
| @ -1,24 +1,14 @@ | ||||
| import { Stack, useRouter } from "expo-router"; | ||||
| import { useEffect, useRef } from "react"; | ||||
| import { StyleSheet, View } from "react-native"; | ||||
| import { | ||||
| 	useEvent, | ||||
| 	useVideoPlayer, | ||||
| 	VideoView, | ||||
| 	VideoViewRef, | ||||
| } from "react-native-video"; | ||||
| import { useEvent, useVideoPlayer, VideoView } from "react-native-video"; | ||||
| import { entryDisplayNumber } from "~/components/entries"; | ||||
| import { FullVideo, VideoInfo } from "~/models"; | ||||
| import { Head } from "~/primitives"; | ||||
| import { ContrastArea, Head } from "~/primitives"; | ||||
| import { useToken } from "~/providers/account-context"; | ||||
| import { useLocalSetting } from "~/providers/settings"; | ||||
| import { type QueryIdentifier, useFetch } from "~/query"; | ||||
| import { useQueryState } from "~/utils"; | ||||
| import { LoadingIndicator } from "./components/hover"; | ||||
| 
 | ||||
| // import { Hover, LoadingIndicator } from "./components/hover";
 | ||||
| // import { useVideoKeyboard } from "./keyboard";
 | ||||
| // import { durationAtom, fullscreenAtom, Video } from "./state";
 | ||||
| import { LoadingIndicator } from "./controls"; | ||||
| 
 | ||||
| const mapMetadata = (item: FullVideo | undefined) => { | ||||
| 	if (!item) return null; | ||||
| @ -142,8 +132,10 @@ export const Player = () => { | ||||
| 				controls | ||||
| 				style={StyleSheet.absoluteFillObject} | ||||
| 			/> | ||||
| 			<LoadingIndicator player={player} /> | ||||
| 			{/* <Hover {...mapData(data, info, previous, next)} url={`${type}/${slug}`} /> */} | ||||
| 			<ContrastArea mode="dark"> | ||||
| 				<LoadingIndicator player={player} /> | ||||
| 				<Controls player={player} {...metadata} /> | ||||
| 			</ContrastArea> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| @ -31,7 +31,7 @@ import { | ||||
| 	progressAtom, | ||||
| 	subtitleAtom, | ||||
| 	volumeAtom, | ||||
| } from "./state"; | ||||
| } from "./old/statee"; | ||||
| 
 | ||||
| type Action = | ||||
| 	| { type: "play" } | ||||
							
								
								
									
										141
									
								
								front/src/ui/player/old/left-buttons.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								front/src/ui/player/old/left-buttons.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,141 @@ | ||||
| import { | ||||
| 	IconButton, | ||||
| 	Link, | ||||
| 	noTouch, | ||||
| 	tooltip, | ||||
| 	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"; | ||||
| 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, | ||||
| 	...props | ||||
| }: { | ||||
| 	previousSlug?: string | null; | ||||
| 	nextSlug?: string | null; | ||||
| }) => { | ||||
| 	const { css } = useYoshiki(); | ||||
| 	const [isPlaying, setPlay] = useAtom(playAtom); | ||||
| 	const hover = useAtomValue(hoverAtom); | ||||
| 
 | ||||
| 	const common = css( | ||||
| 		[ | ||||
| 			{ | ||||
| 				backgroundColor: (theme) => theme.darkOverlay, | ||||
| 				marginHorizontal: ts(3), | ||||
| 			}, | ||||
| 		], | ||||
| 		touchOnly, | ||||
| 	); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<HoverTouch | ||||
| 			{...css( | ||||
| 				{ | ||||
| 					flexDirection: "row", | ||||
| 					justifyContent: "center", | ||||
| 					alignItems: "center", | ||||
| 					position: "absolute", | ||||
| 					top: 0, | ||||
| 					left: 0, | ||||
| 					right: 0, | ||||
| 					bottom: 0, | ||||
| 				}, | ||||
| 				props, | ||||
| 			)} | ||||
| 		> | ||||
| 			{hover && ( | ||||
| 				<> | ||||
| 					<IconButton | ||||
| 						icon={SkipPrevious} | ||||
| 						as={Link} | ||||
| 						href={previousSlug!} | ||||
| 						replace | ||||
| 						size={ts(4)} | ||||
| 						{...css( | ||||
| 							[!previousSlug && { opacity: 0, pointerEvents: "none" }], | ||||
| 							common, | ||||
| 						)} | ||||
| 					/> | ||||
| 					<IconButton | ||||
| 						icon={isPlaying ? Pause : PlayArrow} | ||||
| 						onPress={() => setPlay(!isPlaying)} | ||||
| 						size={ts(8)} | ||||
| 						{...common} | ||||
| 					/> | ||||
| 					<IconButton | ||||
| 						icon={SkipNext} | ||||
| 						as={Link} | ||||
| 						href={nextSlug!} | ||||
| 						replace | ||||
| 						size={ts(4)} | ||||
| 						{...css( | ||||
| 							[!nextSlug && { opacity: 0, pointerEvents: "none" }], | ||||
| 							common, | ||||
| 						)} | ||||
| 					/> | ||||
| 				</> | ||||
| 			)} | ||||
| 		</HoverTouch> | ||||
| 	); | ||||
| }; | ||||
| @ -21,8 +21,8 @@ | ||||
| import { useAtom, useAtomValue, useSetAtom } from "jotai"; | ||||
| import { useEffect } from "react"; | ||||
| import { useRouter } from "solito/router"; | ||||
| import { reducerAtom } from "./keyboard"; | ||||
| import { durationAtom, playAtom, progressAtom } from "./state"; | ||||
| import { reducerAtom } from "./old/keyboardd"; | ||||
| import { durationAtom, playAtom, progressAtom } from "./old/statee"; | ||||
| 
 | ||||
| export const MediaSessionManager = ({ | ||||
| 	title, | ||||
| @ -30,8 +30,8 @@ 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"; | ||||
| import { fullscreenAtom, subtitleAtom } from "./state"; | ||||
| import { AudiosMenu, QualitiesMenu } from "./video"; | ||||
| 
 | ||||
| export const RightButtons = ({ | ||||
| 	audios, | ||||
| @ -25,9 +25,9 @@ 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"; | ||||
| import { durationAtom } from "./state"; | ||||
| import { seekProgressAtom } from "../controls"; | ||||
| import { toTimerString } from "../controls/left-buttonsttons"; | ||||
| 
 | ||||
| type Thumb = { | ||||
| 	from: number; | ||||
| @ -33,7 +33,7 @@ import { | ||||
| } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import { Platform } from "react-native"; | ||||
| import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "./video"; | ||||
| import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "../videoideo"; | ||||
| 
 | ||||
| export const playAtom = atom(true); | ||||
| export const loadAtom = atom(false); | ||||
| @ -50,8 +50,8 @@ import NativeVideo, { | ||||
| 	SelectedVideoTrackType, | ||||
| } from "react-native-video"; | ||||
| import { useYoshiki } from "yoshiki/native"; | ||||
| import { useDisplayName } from "../../../packages/ui/src/utils"; | ||||
| import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./state"; | ||||
| import { useDisplayName } from "../../../../packages/ui/src/utils"; | ||||
| import { PlayMode, audioAtom, playModeAtom, subtitleAtom } from "./old/statee"; | ||||
| 
 | ||||
| const MimeTypes: Map<string, string> = new Map([ | ||||
| 	["subrip", "application/x-subrip"], | ||||
| @ -36,9 +36,9 @@ import { useTranslation } from "react-i18next"; | ||||
| import type { VideoProps } from "react-native-video"; | ||||
| import toVttBlob from "srt-webvtt"; | ||||
| import { useForceRerender, useYoshiki } from "yoshiki"; | ||||
| import { useDisplayName } from "../../../packages/ui/src/utils"; | ||||
| import { MediaSessionManager } from "./media-session"; | ||||
| import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./state"; | ||||
| import { useDisplayName } from "../../../../packages/ui/src/utils"; | ||||
| import { MediaSessionManager } from "./old/media-sessionn"; | ||||
| import { PlayMode, audioAtom, playAtom, playModeAtom, progressAtom, subtitleAtom } from "./old/statee"; | ||||
| 
 | ||||
| let hls: Hls | null = null; | ||||
| 
 | ||||
| @ -23,7 +23,7 @@ import { useMutation } from "@tanstack/react-query"; | ||||
| import { useAtomValue } from "jotai"; | ||||
| import { useAtomCallback } from "jotai/utils"; | ||||
| import { useCallback, useEffect } from "react"; | ||||
| import { playAtom, progressAtom } from "./state"; | ||||
| import { playAtom, progressAtom } from "./old/statee"; | ||||
| 
 | ||||
| export const WatchStatusObserver = ({ | ||||
| 	type, | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user