add new video player

This commit is contained in:
dev.daminik00 2025-10-29 23:37:06 +01:00
parent f6a9fd606b
commit 0cc9ca4e66
6 changed files with 400 additions and 26 deletions

36
package-lock.json generated
View File

@ -12,7 +12,9 @@
"@tanstack/react-virtual": "^3.13.12",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
"hls.js": "^1.6.13",
"idb": "^8.0.3",
"media-chrome": "^4.15.0",
"next": "15.3.3",
"next-intl": "^4.1.0",
"react": "^19.0.0",
@ -20,6 +22,7 @@
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
"shaka-player": "^4.16.7",
"socket.io-client": "^4.8.1",
"zod": "^3.25.64",
"zustand": "^5.0.5"
@ -4565,6 +4568,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/ce-la-react": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/ce-la-react/-/ce-la-react-0.3.1.tgz",
"integrity": "sha512-g0YwpZDPIwTwFumGTzNHcgJA6VhFfFCJkSNdUdC04br2UfU+56JDrJrJva3FZ7MToB4NDHAFBiPE/PZdNl1mQA==",
"license": "BSD-3-Clause",
"peerDependencies": {
"react": ">=17.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -6227,6 +6239,12 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
"license": "Apache-2.0"
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
@ -6964,6 +6982,15 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-chrome": {
"version": "4.15.0",
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.15.0.tgz",
"integrity": "sha512-OgC6m3Ss4cCUEVhvmRdUbnExqVKf8CDRDmbVTjIuRGAXdmz/vc34fL3onSsOm2uDZbhDB4Lb4KArGA48wG8Puw==",
"license": "MIT",
"dependencies": {
"ce-la-react": "^0.3.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -7949,6 +7976,15 @@
"node": ">= 0.4"
}
},
"node_modules/shaka-player": {
"version": "4.16.7",
"resolved": "https://registry.npmjs.org/shaka-player/-/shaka-player-4.16.7.tgz",
"integrity": "sha512-JWIVIxXRDkmogT+3t7UtYDbqY0dHyrfpqYPkKsyTXqBimwEgKFJIayhxwYF7Cn/qTkhp2nocHIdzaVZkaZLK4A==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/sharp": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz",

View File

@ -17,7 +17,9 @@
"@tanstack/react-virtual": "^3.13.12",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
"hls.js": "^1.6.13",
"idb": "^8.0.3",
"media-chrome": "^4.15.0",
"next": "15.3.3",
"next-intl": "^4.1.0",
"react": "^19.0.0",
@ -25,6 +27,7 @@
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
"shaka-player": "^4.16.7",
"socket.io-client": "^4.8.1",
"zod": "^3.25.64",
"zustand": "^5.0.5"

View File

@ -1,11 +1,14 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Icon, IconName, Typography } from "@/components/ui";
import styles from "./VideoGuideView.module.scss";
const VideoPlayer = dynamic(() => import("../VideoPlayer"), { ssr: false });
interface VideoGuideViewProps {
id: string;
name: string;
@ -20,24 +23,12 @@ export default function VideoGuideView({
}: VideoGuideViewProps) {
const router = useRouter();
// Extract video ID from various YouTube URL formats
const getYouTubeVideoId = (url: string): string | null => {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/^([a-zA-Z0-9_-]{11})$/, // Direct video ID
];
// TODO: Remove hardcoded URLs when backend is ready
// Temporary hardcoded DASH/HLS URLs - using for ALL videos until backend updated
const dashUrl = "https://video.witlab.us/videos/TALK_FEELINGS/cmaf/source.mpd";
const hlsUrl = "https://video.witlab.us/videos/TALK_FEELINGS/cmaf/source.m3u8";
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
const videoId = getYouTubeVideoId(videoLink);
const embedUrl = videoId
? `https://www.youtube.com/embed/${videoId}?rel=0&modestbranding=1`
: videoLink;
const _originalVideoLink = videoLink; // Keep for reference when backend is ready
return (
<div className={styles.container}>
@ -60,15 +51,7 @@ export default function VideoGuideView({
<div className={styles.contentWrapper}>
{/* Video Player */}
<div className={styles.videoContainer}>
<div className={styles.videoInner}>
<iframe
src={embedUrl}
title={name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className={styles.video}
/>
</div>
<VideoPlayer mpd={dashUrl} m3u8={hlsUrl} />
</div>
{/* Description */}

View File

@ -0,0 +1,98 @@
.playerWrapper {
position: relative;
width: 100%;
height: 100%;
background: #000;
border-radius: 24px;
overflow: hidden;
}
.video {
width: 100%;
height: auto;
display: block;
background: #000;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
z-index: 1;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
padding: 24px;
text-align: center;
z-index: 1;
p {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
}
.errorSubtext {
font-size: 14px !important;
font-weight: 400 !important;
color: rgba(255, 255, 255, 0.6) !important;
}
.playButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: all 0.3s ease;
&:hover {
transform: translate(-50%, -50%) scale(1.1);
opacity: 0.9;
}
&:active {
transform: translate(-50%, -50%) scale(0.95);
}
svg {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
}
}

View File

@ -0,0 +1,253 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./VideoPlayer.module.scss";
interface VideoPlayerProps {
mpd: string;
m3u8: string;
poster?: string;
autoPlay?: boolean;
}
export default function VideoPlayer({
mpd,
m3u8,
poster,
autoPlay = false,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [showPlayButton, setShowPlayButton] = useState(true);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let shakaPlayer: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let hls: any;
let cleanup = false;
const initPlayer = async () => {
try {
setIsLoading(true);
setHasError(false);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Initializing player...");
// eslint-disable-next-line no-console
console.log("[VideoPlayer] DASH URL:", mpd);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] HLS URL:", m3u8);
// iOS/Safari - нативный HLS
if (video.canPlayType("application/vnd.apple.mpegurl")) {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Using native HLS support");
video.src = m3u8;
setIsLoading(false);
return;
}
// DASH через Shaka (предпочтительно для Android/Desktop)
try {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Trying Shaka Player for DASH...");
const shaka = await import("shaka-player/dist/shaka-player.compiled.js");
if (cleanup) return;
shakaPlayer = new shaka.default.Player(video);
shakaPlayer.configure({
streaming: {
bufferingGoal: 20,
rebufferingGoal: 2,
lowLatencyMode: false,
},
manifest: {
dash: {
ignoreMinBufferTime: true,
},
},
});
await shakaPlayer.load(mpd);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Shaka Player loaded successfully");
setIsLoading(false);
return;
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[VideoPlayer] Shaka failed, fallback to HLS.js", e);
}
// Запасной вариант - HLS.js
try {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Trying HLS.js...");
const Hls = (await import("hls.js")).default;
if (cleanup) return;
if (Hls.isSupported()) {
hls = new Hls({
maxBufferLength: 30,
debug: false,
});
hls.loadSource(m3u8);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] HLS.js manifest parsed");
setIsLoading(false);
});
hls.on(
Hls.Events.ERROR,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_event: any, data: any) => {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] HLS.js error:", data);
if (data.fatal) {
setHasError(true);
setIsLoading(false);
}
},
);
return;
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[VideoPlayer] HLS.js failed", e);
}
// Совсем запасной - прямой src
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Using direct video src");
video.src = m3u8;
setIsLoading(false);
} catch (error) {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] Fatal error:", error);
setHasError(true);
setIsLoading(false);
}
};
initPlayer();
return () => {
cleanup = true;
try {
shakaPlayer?.destroy();
} catch {
// Ignore cleanup errors
}
try {
hls?.destroy();
} catch {
// Ignore cleanup errors
}
};
}, [mpd, m3u8, autoPlay]);
const handlePlay = async () => {
const video = videoRef.current;
if (!video) return;
try {
// Убеждаемся что звук включен
video.muted = false;
await video.play();
setShowPlayButton(false);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Started playing with sound");
} catch (error) {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] Play failed:", error);
}
};
const handleVideoPlay = () => {
setShowPlayButton(false);
};
const handleVideoPause = () => {
// Пауза через нативные контролы
};
return (
<div className={styles.playerWrapper}>
{isLoading && !hasError && (
<div className={styles.loading}>
<div className={styles.spinner} />
</div>
)}
{hasError && (
<div className={styles.error}>
<p>Unable to load video</p>
<p className={styles.errorSubtext}>
Please check your connection and try again
</p>
</div>
)}
{!isLoading && !hasError && showPlayButton && (
<button className={styles.playButton} onClick={handlePlay} type="button">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="40" cy="40" r="40" fill="rgba(255, 255, 255, 0.9)" />
<path
d="M32 24L56 40L32 56V24Z"
fill="#000"
/>
</svg>
</button>
)}
<video
ref={videoRef}
controls
playsInline
preload="metadata"
crossOrigin="anonymous"
poster={poster}
className={styles.video}
style={{
opacity: isLoading ? 0 : 1,
transition: "opacity 0.3s ease",
}}
onLoadedMetadata={() => {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Video metadata loaded");
}}
onCanPlay={() => {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Video can play");
setIsLoading(false);
}}
onPlay={handleVideoPlay}
onPause={handleVideoPause}
onError={(e) => {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] Video element error:", e);
setHasError(true);
setIsLoading(false);
}}
/>
</div>
);
}

View File

@ -0,0 +1 @@
export { default } from "./VideoPlayer";