diff --git a/package-lock.json b/package-lock.json
index 88f7f2e..b1b327b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
@@ -22,6 +24,7 @@
"remark-gfm": "^4.0.1",
"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"
@@ -4629,14 +4632,13 @@
],
"license": "CC-BY-4.0"
},
- "node_modules/ccount": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
- "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
+ "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": {
@@ -6449,6 +6451,12 @@
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
+ }
+ "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",
@@ -7282,6 +7290,20 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "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",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -9227,6 +9249,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",
diff --git a/package.json b/package.json
index 8f567e2..767018e 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -27,6 +29,7 @@
"remark-gfm": "^4.0.1",
"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"
diff --git a/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx
index c00c357..1edebfb 100644
--- a/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx
+++ b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx
@@ -1,12 +1,15 @@
"use client";
import { useEffect, useState } from "react";
+import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Icon, IconName, MarkdownText, Typography } from "@/components/ui";
import styles from "./VideoGuideView.module.scss";
+const VideoPlayer = dynamic(() => import("../VideoPlayer"), { ssr: false });
+
interface VideoGuideViewProps {
id: string;
name: string;
@@ -48,24 +51,12 @@ export default function VideoGuideView({
loadMarkdown();
}, [contentUrl]);
- // 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 (
@@ -88,15 +79,7 @@ export default function VideoGuideView({
{/* Video Player */}
{/* Description or Markdown Content */}
diff --git a/src/components/domains/video-guides/VideoPlayer/VideoPlayer.module.scss b/src/components/domains/video-guides/VideoPlayer/VideoPlayer.module.scss
new file mode 100644
index 0000000..8e63dc3
--- /dev/null
+++ b/src/components/domains/video-guides/VideoPlayer/VideoPlayer.module.scss
@@ -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));
+ }
+}
diff --git a/src/components/domains/video-guides/VideoPlayer/VideoPlayer.tsx b/src/components/domains/video-guides/VideoPlayer/VideoPlayer.tsx
new file mode 100644
index 0000000..aef10c1
--- /dev/null
+++ b/src/components/domains/video-guides/VideoPlayer/VideoPlayer.tsx
@@ -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
(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 (
+
+ {isLoading && !hasError && (
+
+ )}
+
+ {hasError && (
+
+
Unable to load video
+
+ Please check your connection and try again
+
+
+ )}
+
+ {!isLoading && !hasError && showPlayButton && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/domains/video-guides/VideoPlayer/index.ts b/src/components/domains/video-guides/VideoPlayer/index.ts
new file mode 100644
index 0000000..c269b57
--- /dev/null
+++ b/src/components/domains/video-guides/VideoPlayer/index.ts
@@ -0,0 +1 @@
+export { default } from "./VideoPlayer";