Перейти к содержимому

Медиа

Видеоплеер

Кастомизированный видеоплеер с анимированными контролами и полноэкранным режимом.


VideoPlayer

Кастомизированный видеоплеер с анимированными контролами, морфингом иконок и поддержкой полноэкранного режима. Построен на GSAP с плавными переходами.

Демо

Пропсы

<script lang="ts">
	import { onMount, onDestroy, tick } from "svelte";
	import { gsap } from "gsap";
	import { Flip } from "gsap/Flip";
	import { MorphSVGPlugin } from "gsap/dist/MorphSVGPlugin";
	import { registerPluginOnce } from "$lib/helpers/gsap";
	import { cn } from "$lib/utils/cn";
	import VideoSlider from "./VideoSlider.svelte";

	type Props = {
		src: string;
		poster?: string;
		autoplay?: boolean;
		muted?: boolean;
		loop?: boolean;
		hideControls?: boolean;
		class?: string;
	};

	let {
		src,
		poster,
		autoplay = false,
		muted = $bindable(true),
		loop = false,
		hideControls = false,
		class: className,
	}: Props = $props();

	let videoRef: HTMLVideoElement | undefined;
	let containerRef: HTMLElement | undefined;
	let controlsRef: HTMLElement | undefined;
	let bgRef: HTMLElement | undefined;
	let pathRef: SVGPathElement | undefined;
	let playPathRef: SVGPathElement | undefined;
	let mutePathRef: SVGPathElement | undefined;
	let isPlaying = $state(false);
	let isHovered = $state(false);
	let currentTime = $state(0);
	let duration = $state(0);
	let isScrubbing = $state(false);
	let isLayoutFullscreen = $state(false);
	let isExpanded = $state(false);
	let placeholder: HTMLElement | null = null;
	let originalParent: ParentNode | null = null;
	let originalNextSibling: Node | null = null;
	let ctx: gsap.Context | null = null;
	let currentTimeStr = $derived(formatTime(currentTime));
	let durationStr = $derived(formatTime(duration));
	let rafId: number;

	const attachVideoRef = (node: HTMLVideoElement) => {
		videoRef = node;
	};

	const attachContainerRef = (node: HTMLElement) => {
		containerRef = node;
		ctx = gsap.context(() => undefined, node);
		return () => {
			ctx?.revert();
			ctx = null;
		};
	};

	const attachControlsRef = (node: HTMLElement) => {
		controlsRef = node;
	};

	const attachBgRef = (node: HTMLElement) => {
		bgRef = node;
	};

	const attachPathRef = (node: SVGPathElement) => {
		pathRef = node;
	};

	const attachPlayPathRef = (node: SVGPathElement) => {
		playPathRef = node;
	};

	const attachMutePathRef = (node: SVGPathElement) => {
		mutePathRef = node;
	};

	const enterPath = "M15 3h6v6 M9 21H3v-6 M21 3l-7 7 M3 21l7-7";
	const exitPath =
		"M8 3v3a2 2 0 0 1-2 2H3 M21 8h-3a2 2 0 0 1-2-2V3 M3 16h3a2 2 0 0 1 2 2v3 M16 21v-3a2 2 0 0 1 2-2h3";
	const iconPlay = "M7 5v14l11-7z";
	const iconPause = "M6 5h4v14H6zm8 0h4v14h-4z";
	const iconVolume =
		"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z";
	const iconMute =
		"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73 4.27 3zM12 4L9.91 6.09 12 8.18V4z";

	onMount(() => {
		registerPluginOnce(MorphSVGPlugin, Flip);
	});

	async function toggleFullscreen() {
		if (!containerRef) return;
		const container = containerRef;
		const state = Flip.getState(container, { props: "borderRadius" });
		if (!isExpanded) {
			isExpanded = true;

			if (!isLayoutFullscreen) {
				originalParent = container.parentNode;
				originalNextSibling = container.nextSibling;
				const rect = container.getBoundingClientRect();
				placeholder = document.createElement("div");
				placeholder.style.width = `${rect.width.toString()}px`;
				placeholder.style.height = `${rect.height.toString()}px`;

				if (originalParent) {
					originalParent.insertBefore(placeholder, container);
				}
				document.body.appendChild(container);
				isLayoutFullscreen = true;
			}
			await tick();

			Flip.from(state, {
				duration: 0.5,
				ease: "power4.inOut",
				absolute: true,
				zIndex: 9999,
			});
		} else {
			isExpanded = false;

			if (placeholder) {
				Flip.fit(container, placeholder, {
					duration: 0.5,
					ease: "power4.inOut",
					absolute: true,
					borderRadius: "1rem",
					onComplete: () => {
						isLayoutFullscreen = false;

						if (placeholder?.parentNode) {
							placeholder.parentNode.insertBefore(container, placeholder);
							placeholder.remove();
							placeholder = null;
						} else if (originalParent) {
							if (originalNextSibling) {
								originalParent.insertBefore(container, originalNextSibling);
							} else {
								originalParent.appendChild(container);
							}
						}
						gsap.set(container, { clearProps: "all" });
					},
				});
			} else {
				isLayoutFullscreen = false;
				await tick();
			}
		}
	}

	function formatTime(seconds: number) {
		if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
		const m = Math.floor(seconds / 60);
		const s = Math.floor(seconds % 60);
		return `${m.toString()}:${s.toString().padStart(2, "0")}`;
	}

	function updateTime() {
		if (videoRef && !isScrubbing && !videoRef.paused) {
			currentTime = videoRef.currentTime;
		}
		rafId = requestAnimationFrame(updateTime);
	}

	function togglePlay() {
		if (hideControls) return;
		if (!videoRef) return;

		if (videoRef.paused) {
			void videoRef.play();
		} else {
			videoRef.pause();
		}
	}

	function toggleMute() {
		if (videoRef) {
			muted = !muted;
		}
	}

	function onPlay() {
		isPlaying = true;
		rafId = requestAnimationFrame(updateTime);
	}

	function onPause() {
		isPlaying = false;
		cancelAnimationFrame(rafId);
	}

	function onLoadedMetadata() {
		if (!videoRef) return;
		duration = videoRef.duration;
		currentTime = videoRef.currentTime;
	}

	function onEnded() {
		isPlaying = false;
		currentTime = duration;
	}

	function handleScrubStart() {
		isScrubbing = true;
		if (isPlaying) {
			videoRef?.pause();
		}
	}

	function handleScrubEnd() {
		isScrubbing = false;
		if (isPlaying) {
			void videoRef?.play();
		}
	}

	function handleSeek(time: number) {
		if (videoRef) {
			videoRef.currentTime = Math.min(Math.max(time, 0), duration);
			currentTime = time;
		}
	}

	onMount(() => {
		if (videoRef && videoRef.readyState >= 1) {
			onLoadedMetadata();
		}
	});

	onDestroy(() => {
		cancelAnimationFrame(rafId);
		if (
			isLayoutFullscreen &&
			containerRef &&
			document.body.contains(containerRef)
		) {
			document.body.removeChild(containerRef);
		}
		if (placeholder?.parentNode) {
			placeholder.remove();
		}
	});

	$effect(() => {
		if (!playPathRef) return;
		const playPath = playPathRef;

		const localCtx = gsap.context(() => {
			if (isPlaying) {
				gsap.to(playPath, {
					morphSVG: iconPause,
					duration: 0.3,
					ease: "power4.inOut",
				});
			} else {
				gsap.to(playPath, {
					morphSVG: iconPlay,
					duration: 0.3,
					ease: "power4.inOut",
				});
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});

	$effect(() => {
		if (!mutePathRef) return;
		const mutePath = mutePathRef;

		const localCtx = gsap.context(() => {
			if (muted) {
				gsap.to(mutePath, {
					morphSVG: iconMute,
					duration: 0.3,
					ease: "power4.inOut",
				});
			} else {
				gsap.to(mutePath, {
					morphSVG: iconVolume,
					duration: 0.3,
					ease: "power4.inOut",
				});
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});

	$effect(() => {
		if (!pathRef) return;
		const path = pathRef;

		const localCtx = gsap.context(() => {
			if (isExpanded) {
				gsap.to(path, {
					morphSVG: exitPath,
					duration: 0.3,
					ease: "power4.inOut",
				});
			} else {
				gsap.to(path, {
					morphSVG: enterPath,
					duration: 0.3,
					ease: "power4.inOut",
				});
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});

	$effect(() => {
		if (!controlsRef || !bgRef || hideControls) return;
		const controls = controlsRef;
		const bg = bgRef;

		const localCtx = gsap.context(() => {
			if (isHovered || !isPlaying) {
				gsap.to(bg, {
					opacity: 1,
					duration: 0.3,
					ease: "power4.out",
					overwrite: true,
				});
				gsap.to(controls.children, {
					y: 0,
					opacity: 1,
					duration: 0.3,
					ease: "power4.out",
					overwrite: true,
				});
				gsap.set(controls, { pointerEvents: "auto" });
			} else {
				gsap.to(bg, {
					opacity: 0,
					duration: 0.3,
					ease: "power4.in",
					overwrite: true,
				});
				gsap.to(controls.children, {
					y: 20,
					opacity: 0,
					duration: 0.3,
					ease: "power4.in",
					overwrite: true,
				});
				gsap.set(controls, { pointerEvents: "none" });
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});
</script>

<div
	{@attach attachContainerRef}
	onmouseenter={() => (isHovered = true)}
	onmouseleave={() => (isHovered = false)}
	role="region"
	aria-label="Video Player"
	class={cn(
		"group bg-fixed-dark relative flex overflow-hidden shadow-sm",
		isLayoutFullscreen
			? "fixed inset-0 z-50 h-screen w-screen rounded-none"
			: "aspect-video w-full max-w-3xl rounded-xl",
		className,
	)}
>
	<!-- svelte-ignore a11y_media_has_caption -->
	<video
		{@attach attachVideoRef}
		{src}
		{poster}
		{autoplay}
		bind:muted
		{loop}
		class="h-full w-full object-cover"
		playsinline
		onplay={onPlay}
		onpause={onPause}
		onloadedmetadata={onLoadedMetadata}
		onended={onEnded}
		onclick={togglePlay}
	></video>

	<div
		{@attach attachBgRef}
		class={cn(
			"pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-32 bg-linear-to-t from-black/70 via-black/45 to-transparent opacity-0",
			hideControls && "hidden",
		)}
	></div>

	<div
		{@attach attachControlsRef}
		class={cn(
			"pointer-events-none absolute right-0 bottom-0 left-0 z-20 flex items-center gap-3 px-4 pt-10 pb-4",
			hideControls && "hidden",
		)}
	>
		<button
			onclick={togglePlay}
			class="flex size-8 translate-y-5 items-center justify-center rounded-full bg-fixed-light/10 text-fixed-light opacity-0 backdrop-blur-md transition-[background-color,scale] duration-150 ease-out hover:bg-fixed-light/20 active:scale-95"
			aria-label={isPlaying ? "Pause" : "Play"}
		>
			<svg
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="currentColor"
				xmlns="http://www.w3.org/2000/svg"
			>
				<path {@attach attachPlayPathRef} d={iconPlay} />
			</svg>
		</button>

		<div class="flex-1 translate-y-5 opacity-0">
			<VideoSlider
				{currentTime}
				{duration}
				onScrubStart={handleScrubStart}
				onScrubEnd={handleScrubEnd}
				onSeek={handleSeek}
			/>
		</div>

		<div
			class="flex translate-y-5 items-center gap-1 font-mono text-[10px] font-medium text-fixed-light opacity-0"
		>
			<span class="text-fixed-light/70">{currentTimeStr}</span>
			<span>/</span>
			<span>{durationStr}</span>
		</div>

		<button
			onclick={toggleMute}
			class="flex size-8 translate-y-5 items-center justify-center rounded-full bg-fixed-light/10 text-fixed-light opacity-0 backdrop-blur-md transition-[background-color,scale] duration-150 ease-out hover:bg-fixed-light/20"
			aria-label={muted ? "Unmute" : "Mute"}
		>
			<svg
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="currentColor"
				xmlns="http://www.w3.org/2000/svg"
			>
				<path {@attach attachMutePathRef} d={iconVolume} />
			</svg>
		</button>

		<button
			onclick={toggleFullscreen}
			class="flex size-8 translate-y-5 items-center justify-center rounded-full bg-fixed-light/10 text-fixed-light opacity-0 backdrop-blur-md transition-[background-color,scale] duration-150 ease-out hover:bg-fixed-light/20"
			aria-label={isExpanded ? "Exit Fullscreen" : "Enter Fullscreen"}
		>
			<svg
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="none"
				stroke="currentColor"
				stroke-width="2"
				stroke-linecap="round"
				stroke-linejoin="round"
			>
				<path {@attach attachPathRef} d={enterPath} />
			</svg>
		</button>
	</div>
</div>
<script lang="ts">
	import { onMount, onDestroy, tick } from "svelte";
	import { gsap } from "gsap";
	import { Flip } from "gsap/Flip";
	import { MorphSVGPlugin } from "gsap/dist/MorphSVGPlugin";
	import { registerPluginOnce } from "$lib/helpers/gsap";
	import { cn } from "$lib/utils/cn";
	import VideoSlider from "./VideoSlider.svelte";

	type Props = {
		src: string;
		poster?: string;
		autoplay?: boolean;
		muted?: boolean;
		loop?: boolean;
		hideControls?: boolean;
		class?: string;
	};

	let {
		src,
		poster,
		autoplay = false,
		muted = $bindable(true),
		loop = false,
		hideControls = false,
		class: className,
	}: Props = $props();

	let videoRef: HTMLVideoElement | undefined;
	let containerRef: HTMLElement | undefined;
	let controlsRef: HTMLElement | undefined;
	let bgRef: HTMLElement | undefined;
	let pathRef: SVGPathElement | undefined;
	let playPathRef: SVGPathElement | undefined;
	let mutePathRef: SVGPathElement | undefined;
	let isPlaying = $state(false);
	let isHovered = $state(false);
	let currentTime = $state(0);
	let duration = $state(0);
	let isScrubbing = $state(false);
	let isLayoutFullscreen = $state(false);
	let isExpanded = $state(false);
	let placeholder: HTMLElement | null = null;
	let originalParent: ParentNode | null = null;
	let originalNextSibling: Node | null = null;
	let ctx: gsap.Context | null = null;
	let currentTimeStr = $derived(formatTime(currentTime));
	let durationStr = $derived(formatTime(duration));
	let rafId: number;

	const attachVideoRef = (node: HTMLVideoElement) => {
		videoRef = node;
	};

	const attachContainerRef = (node: HTMLElement) => {
		containerRef = node;
		ctx = gsap.context(() => undefined, node);
		return () => {
			ctx?.revert();
			ctx = null;
		};
	};

	const attachControlsRef = (node: HTMLElement) => {
		controlsRef = node;
	};

	const attachBgRef = (node: HTMLElement) => {
		bgRef = node;
	};

	const attachPathRef = (node: SVGPathElement) => {
		pathRef = node;
	};

	const attachPlayPathRef = (node: SVGPathElement) => {
		playPathRef = node;
	};

	const attachMutePathRef = (node: SVGPathElement) => {
		mutePathRef = node;
	};

	const enterPath = "M15 3h6v6 M9 21H3v-6 M21 3l-7 7 M3 21l7-7";
	const exitPath =
		"M8 3v3a2 2 0 0 1-2 2H3 M21 8h-3a2 2 0 0 1-2-2V3 M3 16h3a2 2 0 0 1 2 2v3 M16 21v-3a2 2 0 0 1 2-2h3";
	const iconPlay = "M7 5v14l11-7z";
	const iconPause = "M6 5h4v14H6zm8 0h4v14h-4z";
	const iconVolume =
		"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z";
	const iconMute =
		"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73 4.27 3zM12 4L9.91 6.09 12 8.18V4z";

	onMount(() => {
		registerPluginOnce(MorphSVGPlugin, Flip);
	});

	async function toggleFullscreen() {
		if (!containerRef) return;
		const container = containerRef;
		const state = Flip.getState(container, { props: "borderRadius" });
		if (!isExpanded) {
			isExpanded = true;

			if (!isLayoutFullscreen) {
				originalParent = container.parentNode;
				originalNextSibling = container.nextSibling;
				const rect = container.getBoundingClientRect();
				placeholder = document.createElement("div");
				placeholder.style.width = `${rect.width.toString()}px`;
				placeholder.style.height = `${rect.height.toString()}px`;

				if (originalParent) {
					originalParent.insertBefore(placeholder, container);
				}
				document.body.appendChild(container);
				isLayoutFullscreen = true;
			}
			await tick();

			Flip.from(state, {
				duration: 0.5,
				ease: "power4.inOut",
				absolute: true,
				zIndex: 9999,
			});
		} else {
			isExpanded = false;

			if (placeholder) {
				Flip.fit(container, placeholder, {
					duration: 0.5,
					ease: "power4.inOut",
					absolute: true,
					borderRadius: "1rem",
					onComplete: () => {
						isLayoutFullscreen = false;

						if (placeholder?.parentNode) {
							placeholder.parentNode.insertBefore(container, placeholder);
							placeholder.remove();
							placeholder = null;
						} else if (originalParent) {
							if (originalNextSibling) {
								originalParent.insertBefore(container, originalNextSibling);
							} else {
								originalParent.appendChild(container);
							}
						}
						gsap.set(container, { clearProps: "all" });
					},
				});
			} else {
				isLayoutFullscreen = false;
				await tick();
			}
		}
	}

	function formatTime(seconds: number) {
		if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
		const m = Math.floor(seconds / 60);
		const s = Math.floor(seconds % 60);
		return `${m.toString()}:${s.toString().padStart(2, "0")}`;
	}

	function updateTime() {
		if (videoRef && !isScrubbing && !videoRef.paused) {
			currentTime = videoRef.currentTime;
		}
		rafId = requestAnimationFrame(updateTime);
	}

	function togglePlay() {
		if (hideControls) return;
		if (!videoRef) return;

		if (videoRef.paused) {
			void videoRef.play();
		} else {
			videoRef.pause();
		}
	}

	function toggleMute() {
		if (videoRef) {
			muted = !muted;
		}
	}

	function onPlay() {
		isPlaying = true;
		rafId = requestAnimationFrame(updateTime);
	}

	function onPause() {
		isPlaying = false;
		cancelAnimationFrame(rafId);
	}

	function onLoadedMetadata() {
		if (!videoRef) return;
		duration = videoRef.duration;
		currentTime = videoRef.currentTime;
	}

	function onEnded() {
		isPlaying = false;
		currentTime = duration;
	}

	function handleScrubStart() {
		isScrubbing = true;
		if (isPlaying) {
			videoRef?.pause();
		}
	}

	function handleScrubEnd() {
		isScrubbing = false;
		if (isPlaying) {
			void videoRef?.play();
		}
	}

	function handleSeek(time: number) {
		if (videoRef) {
			videoRef.currentTime = Math.min(Math.max(time, 0), duration);
			currentTime = time;
		}
	}

	onMount(() => {
		if (videoRef && videoRef.readyState >= 1) {
			onLoadedMetadata();
		}
	});

	onDestroy(() => {
		cancelAnimationFrame(rafId);
		if (
			isLayoutFullscreen &&
			containerRef &&
			document.body.contains(containerRef)
		) {
			document.body.removeChild(containerRef);
		}
		if (placeholder?.parentNode) {
			placeholder.remove();
		}
	});

	$effect(() => {
		if (!playPathRef) return;
		const playPath = playPathRef;

		const localCtx = gsap.context(() => {
			if (isPlaying) {
				gsap.to(playPath, {
					morphSVG: iconPause,
					duration: 0.3,
					ease: "power4.inOut",
				});
			} else {
				gsap.to(playPath, {
					morphSVG: iconPlay,
					duration: 0.3,
					ease: "power4.inOut",
				});
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});

	$effect(() => {
		if (!mutePathRef) return;
		const mutePath = mutePathRef;

		const localCtx = gsap.context(() => {
			if (muted) {
				gsap.to(mutePath, {
					morphSVG: iconMute,
					duration: 0.3,
					ease: "power4.inOut",
				});
			} else {
				gsap.to(mutePath, {
					morphSVG: iconVolume,
					duration: 0.3,
					ease: "power4.inOut",
				});
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});

	$effect(() => {
		if (!pathRef) return;
		const path = pathRef;

		const localCtx = gsap.context(() => {
			if (isExpanded) {
				gsap.to(path, {
					morphSVG: exitPath,
					duration: 0.3,
					ease: "power4.inOut",
				});
			} else {
				gsap.to(path, {
					morphSVG: enterPath,
					duration: 0.3,
					ease: "power4.inOut",
				});
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});

	$effect(() => {
		if (!controlsRef || !bgRef || hideControls) return;
		const controls = controlsRef;
		const bg = bgRef;

		const localCtx = gsap.context(() => {
			if (isHovered || !isPlaying) {
				gsap.to(bg, {
					opacity: 1,
					duration: 0.3,
					ease: "power4.out",
					overwrite: true,
				});
				gsap.to(controls.children, {
					y: 0,
					opacity: 1,
					duration: 0.3,
					ease: "power4.out",
					overwrite: true,
				});
				gsap.set(controls, { pointerEvents: "auto" });
			} else {
				gsap.to(bg, {
					opacity: 0,
					duration: 0.3,
					ease: "power4.in",
					overwrite: true,
				});
				gsap.to(controls.children, {
					y: 20,
					opacity: 0,
					duration: 0.3,
					ease: "power4.in",
					overwrite: true,
				});
				gsap.set(controls, { pointerEvents: "none" });
			}
		}, containerRef);
		return () => {
			localCtx.revert();
		};
	});
</script>

<div
	{@attach attachContainerRef}
	onmouseenter={() => (isHovered = true)}
	onmouseleave={() => (isHovered = false)}
	role="region"
	aria-label="Video Player"
	class={cn(
		"group bg-fixed-dark relative flex overflow-hidden shadow-sm",
		isLayoutFullscreen
			? "fixed inset-0 z-50 h-screen w-screen rounded-none"
			: "aspect-video w-full max-w-3xl rounded-xl",
		className,
	)}
>
	<!-- svelte-ignore a11y_media_has_caption -->
	<video
		{@attach attachVideoRef}
		{src}
		{poster}
		{autoplay}
		bind:muted
		{loop}
		class="h-full w-full object-cover"
		playsinline
		onplay={onPlay}
		onpause={onPause}
		onloadedmetadata={onLoadedMetadata}
		onended={onEnded}
		onclick={togglePlay}
	></video>

	<div
		{@attach attachBgRef}
		class={cn(
			"pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-32 bg-linear-to-t from-black/70 via-black/45 to-transparent opacity-0",
			hideControls && "hidden",
		)}
	></div>

	<div
		{@attach attachControlsRef}
		class={cn(
			"pointer-events-none absolute right-0 bottom-0 left-0 z-20 flex items-center gap-3 px-4 pt-10 pb-4",
			hideControls && "hidden",
		)}
	>
		<button
			onclick={togglePlay}
			class="flex size-8 translate-y-5 items-center justify-center rounded-full bg-fixed-light/10 text-fixed-light opacity-0 backdrop-blur-md transition-[background-color,scale] duration-150 ease-out hover:bg-fixed-light/20 active:scale-95"
			aria-label={isPlaying ? "Pause" : "Play"}
		>
			<svg
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="currentColor"
				xmlns="http://www.w3.org/2000/svg"
			>
				<path {@attach attachPlayPathRef} d={iconPlay} />
			</svg>
		</button>

		<div class="flex-1 translate-y-5 opacity-0">
			<VideoSlider
				{currentTime}
				{duration}
				onScrubStart={handleScrubStart}
				onScrubEnd={handleScrubEnd}
				onSeek={handleSeek}
			/>
		</div>

		<div
			class="flex translate-y-5 items-center gap-1 font-mono text-[10px] font-medium text-fixed-light opacity-0"
		>
			<span class="text-fixed-light/70">{currentTimeStr}</span>
			<span>/</span>
			<span>{durationStr}</span>
		</div>

		<button
			onclick={toggleMute}
			class="flex size-8 translate-y-5 items-center justify-center rounded-full bg-fixed-light/10 text-fixed-light opacity-0 backdrop-blur-md transition-[background-color,scale] duration-150 ease-out hover:bg-fixed-light/20"
			aria-label={muted ? "Unmute" : "Mute"}
		>
			<svg
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="currentColor"
				xmlns="http://www.w3.org/2000/svg"
			>
				<path {@attach attachMutePathRef} d={iconVolume} />
			</svg>
		</button>

		<button
			onclick={toggleFullscreen}
			class="flex size-8 translate-y-5 items-center justify-center rounded-full bg-fixed-light/10 text-fixed-light opacity-0 backdrop-blur-md transition-[background-color,scale] duration-150 ease-out hover:bg-fixed-light/20"
			aria-label={isExpanded ? "Exit Fullscreen" : "Enter Fullscreen"}
		>
			<svg
				width="16"
				height="16"
				viewBox="0 0 24 24"
				fill="none"
				stroke="currentColor"
				stroke-width="2"
				stroke-linecap="round"
				stroke-linejoin="round"
			>
				<path {@attach attachPathRef} d={enterPath} />
			</svg>
		</button>
	</div>
</div>

Использование

Базовый плеер

<VideoPlayer
	src="/video.mp4"
	poster="/poster.jpg"
/>
<VideoPlayer
	src="/video.mp4"
	poster="/poster.jpg"
/>

YouTube embed

Для встраивания видео с YouTube используйте iframe внутри сниппета children. Это даёт больше контроля над оформлением и позволяет использовать внешние источники:

<VideoPlayer>
	{#snippet children()}
		<iframe
			src="https://www.youtube.com/embed/VIDEO_ID"
			title="Video"
			frameborder="0"
			allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
			allowfullscreen
			class="aspect-video w-full"
		></iframe>
	{/snippet}
</VideoPlayer>
<VideoPlayer>
	{#snippet children()}
		<iframe
			src="https://www.youtube.com/embed/VIDEO_ID"
			title="Video"
			frameborder="0"
			allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
			allowfullscreen
			class="aspect-video w-full"
		></iframe>
	{/snippet}
</VideoPlayer>

Автовоспроизведение

<VideoPlayer
	src="/video.mp4"
	autoplay={true}
	muted={true}
/>
<VideoPlayer
	src="/video.mp4"
	autoplay={true}
	muted={true}
/>

Скрытие контролов

<VideoPlayer
	src="/video.mp4"
	hideControls={true}
/>
<VideoPlayer
	src="/video.mp4"
	hideControls={true}
/>

Пропсы

Пропс Тип По умолчанию Описание
src string URL видеофайла (обязательный)
poster string URL постера
autoplay boolean false Автовоспроизведение
muted boolean true Отключение звука
loop boolean false Зацикливание
hideControls boolean false Скрыть контролы
class string Дополнительные классы

Возможности

  • Плавные анимации GSAP для всех элементов управления
  • Морфинг иконок (play/pause, mute/unmute, fullscreen)
  • Полноэкранный режим с анимацией
  • Прогресс-бар с предпросмотром времени
  • Управление с клавиатуры (стрелки, Page Up/Down, Home/End)