add new video player
This commit is contained in:
parent
f6a9fd606b
commit
0cc9ca4e66
36
package-lock.json
generated
36
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
253
src/components/domains/video-guides/VideoPlayer/VideoPlayer.tsx
Normal file
253
src/components/domains/video-guides/VideoPlayer/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/domains/video-guides/VideoPlayer/index.ts
Normal file
1
src/components/domains/video-guides/VideoPlayer/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./VideoPlayer";
|
||||
Loading…
Reference in New Issue
Block a user