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

Медиа

Видеоплеер

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


Видеоплеер

Кастомизированный видеоплеер с анимированными контролами, морфингом иконок и поддержкой полноэкранного режима. Построен на 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)