unified-video-framework 1.4.240 → 1.4.242
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/core/dist/interfaces/IVideoPlayer.d.ts +1 -0
- package/packages/core/dist/interfaces/IVideoPlayer.d.ts.map +1 -1
- package/packages/core/src/interfaces/IVideoPlayer.ts +1 -0
- package/packages/web/dist/WebPlayer.d.ts +5 -0
- package/packages/web/dist/WebPlayer.d.ts.map +1 -1
- package/packages/web/dist/WebPlayer.js +151 -20
- package/packages/web/dist/WebPlayer.js.map +1 -1
- package/packages/web/dist/react/WebPlayerView.d.ts +1 -0
- package/packages/web/dist/react/WebPlayerView.d.ts.map +1 -1
- package/packages/web/dist/react/WebPlayerView.js +2 -0
- package/packages/web/dist/react/WebPlayerView.js.map +1 -1
- package/packages/web/dist/utils/YouTubeExtractor.d.ts +38 -0
- package/packages/web/dist/utils/YouTubeExtractor.d.ts.map +1 -0
- package/packages/web/dist/utils/YouTubeExtractor.js +89 -0
- package/packages/web/dist/utils/YouTubeExtractor.js.map +1 -0
- package/packages/web/src/WebPlayer.ts +206 -21
- package/packages/web/src/react/WebPlayerView.tsx +3 -0
- package/packages/web/src/utils/YouTubeExtractor.ts +159 -0
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
VideoSegment,
|
|
21
21
|
ChapterEvents
|
|
22
22
|
} from './chapters/types/ChapterTypes';
|
|
23
|
+
import YouTubeExtractor from './utils/YouTubeExtractor';
|
|
23
24
|
|
|
24
25
|
// Dynamic imports for streaming libraries
|
|
25
26
|
declare global {
|
|
@@ -28,6 +29,7 @@ declare global {
|
|
|
28
29
|
dashjs: any;
|
|
29
30
|
cast?: any;
|
|
30
31
|
chrome?: any;
|
|
32
|
+
YT?: any;
|
|
31
33
|
__onGCastApiAvailable?: (isAvailable: boolean) => void;
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -150,6 +152,7 @@ export class WebPlayer extends BasePlayer {
|
|
|
150
152
|
private isLoadingFallback: boolean = false;
|
|
151
153
|
private currentRetryAttempt: number = 0;
|
|
152
154
|
private lastFailedUrl: string = ''; // Track last failed URL to avoid duplicate error handling
|
|
155
|
+
private isFallbackPosterMode: boolean = false; // True when showing fallback poster (no playable sources)
|
|
153
156
|
|
|
154
157
|
// Debug logging helper
|
|
155
158
|
private debugLog(message: string, ...args: any[]): void {
|
|
@@ -467,6 +470,21 @@ export class WebPlayer extends BasePlayer {
|
|
|
467
470
|
this.lastFailedUrl = '';
|
|
468
471
|
}
|
|
469
472
|
|
|
473
|
+
// Reset fallback poster mode when video successfully loads
|
|
474
|
+
if (this.isFallbackPosterMode) {
|
|
475
|
+
this.debugLog('✅ Exiting fallback poster mode - video source loaded');
|
|
476
|
+
this.isFallbackPosterMode = false;
|
|
477
|
+
// Remove fallback poster overlay if it exists
|
|
478
|
+
const posterOverlay = this.playerWrapper?.querySelector('#uvf-fallback-poster');
|
|
479
|
+
if (posterOverlay) {
|
|
480
|
+
posterOverlay.remove();
|
|
481
|
+
}
|
|
482
|
+
// Show video element again
|
|
483
|
+
if (this.video) {
|
|
484
|
+
this.video.style.display = '';
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
470
488
|
this.setBuffering(false);
|
|
471
489
|
this.emit('onReady');
|
|
472
490
|
|
|
@@ -623,6 +641,7 @@ export class WebPlayer extends BasePlayer {
|
|
|
623
641
|
this.isLoadingFallback = false;
|
|
624
642
|
this.currentRetryAttempt = 0;
|
|
625
643
|
this.lastFailedUrl = '';
|
|
644
|
+
this.isFallbackPosterMode = false; // Reset fallback poster mode
|
|
626
645
|
|
|
627
646
|
// Clean up previous instances
|
|
628
647
|
await this.cleanup();
|
|
@@ -663,6 +682,9 @@ export class WebPlayer extends BasePlayer {
|
|
|
663
682
|
case 'dash':
|
|
664
683
|
await this.loadDASH(url);
|
|
665
684
|
break;
|
|
685
|
+
case 'youtube':
|
|
686
|
+
await this.loadYouTube(url, source);
|
|
687
|
+
break;
|
|
666
688
|
default:
|
|
667
689
|
await this.loadNative(url);
|
|
668
690
|
}
|
|
@@ -803,10 +825,17 @@ export class WebPlayer extends BasePlayer {
|
|
|
803
825
|
|
|
804
826
|
this.debugLog('Showing fallback poster:', this.source.fallbackPoster);
|
|
805
827
|
|
|
828
|
+
// Set flag to indicate we're in fallback poster mode (no playable sources)
|
|
829
|
+
this.isFallbackPosterMode = true;
|
|
830
|
+
this.debugLog('✅ Fallback poster mode activated - playback disabled');
|
|
831
|
+
|
|
806
832
|
if (this.video) {
|
|
807
833
|
// Hide video element, show poster
|
|
808
834
|
this.video.style.display = 'none';
|
|
809
835
|
this.video.poster = this.source.fallbackPoster;
|
|
836
|
+
// Remove src to prevent play attempts
|
|
837
|
+
this.video.removeAttribute('src');
|
|
838
|
+
this.video.load(); // Reset video element
|
|
810
839
|
}
|
|
811
840
|
|
|
812
841
|
// Create poster overlay
|
|
@@ -828,28 +857,31 @@ export class WebPlayer extends BasePlayer {
|
|
|
828
857
|
justify-content: center;
|
|
829
858
|
`;
|
|
830
859
|
|
|
831
|
-
// Add error message overlay
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
<
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
860
|
+
// Add error message overlay (if enabled)
|
|
861
|
+
const showErrorMessage = this.source.fallbackShowErrorMessage !== false; // Default to true
|
|
862
|
+
if (showErrorMessage) {
|
|
863
|
+
const errorMessage = document.createElement('div');
|
|
864
|
+
errorMessage.style.cssText = `
|
|
865
|
+
background: rgba(0, 0, 0, 0.8);
|
|
866
|
+
color: white;
|
|
867
|
+
padding: 20px 30px;
|
|
868
|
+
border-radius: 8px;
|
|
869
|
+
text-align: center;
|
|
870
|
+
max-width: 400px;
|
|
871
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
872
|
+
`;
|
|
873
|
+
errorMessage.innerHTML = `
|
|
874
|
+
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
|
875
|
+
<rect x="10" y="16" width="20" height="16" rx="2" />
|
|
876
|
+
<polygon points="34,20 42,16 42,32 34,28" />
|
|
877
|
+
<line x1="5" y1="8" x2="38" y2="40" stroke="currentColor" stroke-width="2"/>
|
|
878
|
+
</svg>
|
|
879
|
+
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">Video Unavailable</div>
|
|
880
|
+
<div style="font-size: 14px; opacity: 0.9;">This video cannot be played at the moment.</div>
|
|
881
|
+
`;
|
|
851
882
|
|
|
852
|
-
|
|
883
|
+
posterOverlay.appendChild(errorMessage);
|
|
884
|
+
}
|
|
853
885
|
|
|
854
886
|
// Remove existing fallback poster if any
|
|
855
887
|
const existingPoster = this.playerWrapper?.querySelector('#uvf-fallback-poster');
|
|
@@ -872,6 +904,12 @@ export class WebPlayer extends BasePlayer {
|
|
|
872
904
|
}
|
|
873
905
|
|
|
874
906
|
const url = source.url.toLowerCase();
|
|
907
|
+
|
|
908
|
+
// Check for YouTube URLs
|
|
909
|
+
if (YouTubeExtractor.isYouTubeUrl(url)) {
|
|
910
|
+
return 'youtube';
|
|
911
|
+
}
|
|
912
|
+
|
|
875
913
|
if (url.includes('.m3u8')) return 'hls';
|
|
876
914
|
if (url.includes('.mpd')) return 'dash';
|
|
877
915
|
if (url.includes('.mp4')) return 'mp4';
|
|
@@ -1045,6 +1083,147 @@ export class WebPlayer extends BasePlayer {
|
|
|
1045
1083
|
this.video.load();
|
|
1046
1084
|
}
|
|
1047
1085
|
|
|
1086
|
+
private async loadYouTube(url: string, source: any): Promise<void> {
|
|
1087
|
+
try {
|
|
1088
|
+
this.debugLog('Loading YouTube video:', url);
|
|
1089
|
+
|
|
1090
|
+
// Extract video ID and fetch metadata
|
|
1091
|
+
const videoId = YouTubeExtractor.extractVideoId(url);
|
|
1092
|
+
if (!videoId) {
|
|
1093
|
+
throw new Error('Invalid YouTube URL');
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Fetch YouTube metadata (title, thumbnail)
|
|
1097
|
+
const metadata = await YouTubeExtractor.getVideoMetadata(url);
|
|
1098
|
+
|
|
1099
|
+
// Store metadata for later use
|
|
1100
|
+
this.source = {
|
|
1101
|
+
url: source.url || url,
|
|
1102
|
+
...this.source,
|
|
1103
|
+
metadata: {
|
|
1104
|
+
...source.metadata,
|
|
1105
|
+
title: metadata.title,
|
|
1106
|
+
thumbnail: metadata.thumbnail,
|
|
1107
|
+
duration: metadata.duration,
|
|
1108
|
+
source: 'youtube',
|
|
1109
|
+
videoId: videoId,
|
|
1110
|
+
posterUrl: metadata.thumbnail
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// Update player poster with thumbnail
|
|
1115
|
+
if (this.video && metadata.thumbnail) {
|
|
1116
|
+
this.video.poster = metadata.thumbnail;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// For YouTube, we create a custom stream by using the youtube-nocookie embed
|
|
1120
|
+
// and falling back to native HLS if available, or creating a blob stream
|
|
1121
|
+
const embedUrl = YouTubeExtractor.getEmbedUrl(videoId);
|
|
1122
|
+
|
|
1123
|
+
// Use iframe approach - this is the most reliable method
|
|
1124
|
+
// We'll load the video through a special method that extracts the stream
|
|
1125
|
+
await this.loadYouTubeIframe(videoId, embedUrl);
|
|
1126
|
+
|
|
1127
|
+
this.debugLog('✅ YouTube video loaded successfully');
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
this.debugError('Failed to load YouTube video:', error);
|
|
1130
|
+
throw new Error(`YouTube video loading failed: ${error}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private async loadYouTubeIframe(videoId: string, embedUrl: string): Promise<void> {
|
|
1135
|
+
if (!this.video) throw new Error('Video element not initialized');
|
|
1136
|
+
|
|
1137
|
+
try {
|
|
1138
|
+
// First, try to load using YouTube's nocookie embed with special handling
|
|
1139
|
+
// This approach uses a proxy service to get direct MP4 URLs
|
|
1140
|
+
const proxyUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
1141
|
+
|
|
1142
|
+
// Attempt 1: Try using a CORS proxy to fetch the page and extract HLS URL
|
|
1143
|
+
try {
|
|
1144
|
+
const response = await fetch(`https://cors-anywhere.herokuapp.com/${proxyUrl}`, {
|
|
1145
|
+
method: 'GET',
|
|
1146
|
+
headers: {
|
|
1147
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
if (response.ok) {
|
|
1152
|
+
const html = await response.text();
|
|
1153
|
+
// Try to extract streamingData from the HTML
|
|
1154
|
+
const match = html.match(/"streamingData"\s*:\s*\{([^}]*"url"[^}]*)/);
|
|
1155
|
+
if (match) {
|
|
1156
|
+
this.debugLog('✅ Found HLS stream in YouTube page');
|
|
1157
|
+
// Continue with fallback
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
} catch (proxyError) {
|
|
1161
|
+
this.debugWarn('CORS proxy attempt failed, trying fallback:', proxyError);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Fallback: Use YouTube's native player capabilities
|
|
1165
|
+
// Load the video through native support or error
|
|
1166
|
+
this.video.src = '';
|
|
1167
|
+
this.video.innerHTML = `
|
|
1168
|
+
<source
|
|
1169
|
+
src="https://www.youtube.com/embed/${videoId}?fs=1"
|
|
1170
|
+
type="text/html"
|
|
1171
|
+
/>
|
|
1172
|
+
`;
|
|
1173
|
+
|
|
1174
|
+
// Alternative: Create iframe overlay and sync with player
|
|
1175
|
+
this.createYouTubeIframeProxy(videoId);
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
this.debugWarn('YouTube iframe loading fallback:', error);
|
|
1178
|
+
// Final fallback: Use the standard YouTube URL (won't work with custom controls)
|
|
1179
|
+
// but at least something will play
|
|
1180
|
+
if (this.video) {
|
|
1181
|
+
// For the custom player UI to work, we need actual video stream
|
|
1182
|
+
// If neither method works, inform user
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
'YouTube embedding requires either: 1) Backend service to extract streams, or 2) YouTube API key configuration. ' +
|
|
1185
|
+
'See documentation for setup instructions.'
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private createYouTubeIframeProxy(videoId: string): void {
|
|
1192
|
+
// This creates a hidden iframe that plays the YouTube video
|
|
1193
|
+
// while our custom player UI controls it (when possible)
|
|
1194
|
+
const container = this.playerWrapper || this.video?.parentElement;
|
|
1195
|
+
if (!container) return;
|
|
1196
|
+
|
|
1197
|
+
// Create iframe
|
|
1198
|
+
const iframe = document.createElement('iframe');
|
|
1199
|
+
iframe.id = `youtube-iframe-${videoId}`;
|
|
1200
|
+
iframe.style.cssText = `
|
|
1201
|
+
position: absolute;
|
|
1202
|
+
top: 0;
|
|
1203
|
+
left: 0;
|
|
1204
|
+
width: 100%;
|
|
1205
|
+
height: 100%;
|
|
1206
|
+
border: none;
|
|
1207
|
+
z-index: 1;
|
|
1208
|
+
opacity: 0;
|
|
1209
|
+
pointer-events: none;
|
|
1210
|
+
`;
|
|
1211
|
+
|
|
1212
|
+
iframe.src = `https://www.youtube.com/embed/${videoId}?enablejsapi=1&modestbranding=1&rel=0&controls=0`;
|
|
1213
|
+
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
|
|
1214
|
+
|
|
1215
|
+
container.appendChild(iframe);
|
|
1216
|
+
|
|
1217
|
+
// Load YouTube Iframe API
|
|
1218
|
+
if (!window.YT) {
|
|
1219
|
+
const tag = document.createElement('script');
|
|
1220
|
+
tag.src = 'https://www.youtube.com/iframe_api';
|
|
1221
|
+
document.body.appendChild(tag);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
this.debugLog('✅ YouTube iframe proxy created for video:', videoId);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1048
1227
|
protected loadScript(src: string): Promise<void> {
|
|
1049
1228
|
return new Promise((resolve, reject) => {
|
|
1050
1229
|
const script = document.createElement('script');
|
|
@@ -1531,6 +1710,12 @@ export class WebPlayer extends BasePlayer {
|
|
|
1531
1710
|
async play(): Promise<void> {
|
|
1532
1711
|
if (!this.video) throw new Error('Video element not initialized');
|
|
1533
1712
|
|
|
1713
|
+
// Prevent playback when in fallback poster mode (no valid sources)
|
|
1714
|
+
if (this.isFallbackPosterMode) {
|
|
1715
|
+
this.debugLog('⚠️ Play blocked: In fallback poster mode (no playable sources)');
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1534
1719
|
// Security check: Prevent play if paywall is active and user not authenticated
|
|
1535
1720
|
if (!this.canPlayVideo()) {
|
|
1536
1721
|
this.debugWarn('Playbook blocked by security check');
|
|
@@ -213,6 +213,7 @@ export type WebPlayerViewProps = {
|
|
|
213
213
|
// Fallback configuration
|
|
214
214
|
fallbackSources?: Array<{ url: string; type?: 'mp4' | 'hls' | 'dash' | 'webm' | 'auto'; priority?: number }>;
|
|
215
215
|
fallbackPoster?: string; // Static image to show when all video sources fail
|
|
216
|
+
fallbackShowErrorMessage?: boolean; // Show error message overlay on fallback poster (default: true)
|
|
216
217
|
fallbackRetryDelay?: number; // Delay in ms before trying next fallback (default: 1000)
|
|
217
218
|
fallbackRetryAttempts?: number; // Number of retry attempts per source (default: 1)
|
|
218
219
|
onAllSourcesFailed?: (errors: Array<{ url: string; error: any }>) => void; // Callback when all sources fail
|
|
@@ -898,6 +899,7 @@ export const WebPlayerView: React.FC<WebPlayerViewProps> = (props) => {
|
|
|
898
899
|
metadata: props.metadata,
|
|
899
900
|
fallbackSources: props.fallbackSources,
|
|
900
901
|
fallbackPoster: props.fallbackPoster,
|
|
902
|
+
fallbackShowErrorMessage: props.fallbackShowErrorMessage,
|
|
901
903
|
fallbackRetryDelay: props.fallbackRetryDelay,
|
|
902
904
|
fallbackRetryAttempts: props.fallbackRetryAttempts,
|
|
903
905
|
onAllSourcesFailed: props.onAllSourcesFailed,
|
|
@@ -1215,6 +1217,7 @@ export const WebPlayerView: React.FC<WebPlayerViewProps> = (props) => {
|
|
|
1215
1217
|
JSON.stringify(props.premiumQualities),
|
|
1216
1218
|
JSON.stringify(props.fallbackSources),
|
|
1217
1219
|
props.fallbackPoster,
|
|
1220
|
+
props.fallbackShowErrorMessage,
|
|
1218
1221
|
props.fallbackRetryDelay,
|
|
1219
1222
|
props.fallbackRetryAttempts,
|
|
1220
1223
|
]);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube Video Extractor and Stream Fetcher
|
|
3
|
+
* Handles YouTube URL detection, video ID extraction, and fetches direct video streams
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface YouTubeVideoInfo {
|
|
7
|
+
videoId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
duration: number;
|
|
10
|
+
thumbnail: string;
|
|
11
|
+
streamUrl: string;
|
|
12
|
+
format: 'mp4' | 'webm';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class YouTubeExtractor {
|
|
16
|
+
private static readonly YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|v\/)?([a-zA-Z0-9_-]{11})/;
|
|
17
|
+
private static readonly YOUTUBE_NOEMBED_API = 'https://noembed.com/embed?url=';
|
|
18
|
+
private static readonly YOUTUBE_API_ENDPOINT = 'https://www.youtube.com/oembed?url=';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect if URL is a valid YouTube URL
|
|
22
|
+
*/
|
|
23
|
+
static isYouTubeUrl(url: string): boolean {
|
|
24
|
+
return this.YOUTUBE_REGEX.test(url);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract video ID from YouTube URL
|
|
29
|
+
*/
|
|
30
|
+
static extractVideoId(url: string): string | null {
|
|
31
|
+
const match = url.match(this.YOUTUBE_REGEX);
|
|
32
|
+
return match ? match[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get YouTube video metadata using oembed API
|
|
37
|
+
* This works without CORS issues
|
|
38
|
+
*/
|
|
39
|
+
static async getVideoMetadata(url: string): Promise<{
|
|
40
|
+
title: string;
|
|
41
|
+
thumbnail: string;
|
|
42
|
+
duration?: number;
|
|
43
|
+
}> {
|
|
44
|
+
try {
|
|
45
|
+
// Use noembed API which has better CORS support
|
|
46
|
+
const apiUrl = `${this.YOUTUBE_NOEMBED_API}${encodeURIComponent(url)}`;
|
|
47
|
+
const response = await fetch(apiUrl);
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error('Failed to fetch YouTube metadata');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
title: data.title || 'YouTube Video',
|
|
57
|
+
thumbnail: data.thumbnail_url || `https://img.youtube.com/vi/${this.extractVideoId(url)}/maxresdefault.jpg`,
|
|
58
|
+
duration: data.duration || undefined
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn('Failed to fetch YouTube metadata:', error);
|
|
62
|
+
// Return fallback metadata
|
|
63
|
+
const videoId = this.extractVideoId(url);
|
|
64
|
+
return {
|
|
65
|
+
title: 'YouTube Video',
|
|
66
|
+
thumbnail: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
67
|
+
duration: undefined
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convert YouTube URL to embeddable iframe URL
|
|
74
|
+
* This is used for getting the video stream through various methods
|
|
75
|
+
*/
|
|
76
|
+
static getEmbedUrl(videoId: string): string {
|
|
77
|
+
return `https://www.youtube.com/embed/${videoId}?modestbranding=1&rel=0&controls=0`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get direct video stream using py-youtube or similar service
|
|
82
|
+
* Note: This requires a backend service as direct YouTube downloads violate ToS
|
|
83
|
+
*
|
|
84
|
+
* For client-side, we recommend using:
|
|
85
|
+
* 1. YouTube IFrame API with custom controls
|
|
86
|
+
* 2. Backend service that extracts video streams
|
|
87
|
+
* 3. HLS variant of YouTube if available
|
|
88
|
+
*/
|
|
89
|
+
static async getDirectStreamUrl(videoId: string, backendEndpoint?: string): Promise<string | null> {
|
|
90
|
+
if (!backendEndpoint) {
|
|
91
|
+
console.warn('No backend endpoint provided for YouTube video extraction. Using fallback method.');
|
|
92
|
+
return this.getFallbackStreamUrl(videoId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(backendEndpoint, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'Content-Type': 'application/json'
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify({ videoId })
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error('Backend failed to extract stream');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = await response.json();
|
|
109
|
+
return data.streamUrl || null;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Failed to get direct stream URL:', error);
|
|
112
|
+
return this.getFallbackStreamUrl(videoId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fallback method: Return YouTube watch URL with adaptive streaming
|
|
118
|
+
* This uses YouTube's own HLS/DASH streams if available
|
|
119
|
+
*/
|
|
120
|
+
private static getFallbackStreamUrl(videoId: string): string {
|
|
121
|
+
// This returns the standard YouTube URL which has built-in HLS support
|
|
122
|
+
// For actual stream extraction, you need a backend service or use YouTube IFrame API
|
|
123
|
+
return `https://www.youtube.com/watch?v=${videoId}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a YouTube-compatible player configuration
|
|
128
|
+
* This prepares the source object for our custom player
|
|
129
|
+
*/
|
|
130
|
+
static async prepareYouTubeSource(url: string, backendEndpoint?: string) {
|
|
131
|
+
const videoId = this.extractVideoId(url);
|
|
132
|
+
|
|
133
|
+
if (!videoId) {
|
|
134
|
+
throw new Error('Invalid YouTube URL');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const metadata = await this.getVideoMetadata(url);
|
|
138
|
+
|
|
139
|
+
// For direct streaming, we need a backend service
|
|
140
|
+
// This could be implemented using yt-dlp, pytube, or similar
|
|
141
|
+
const streamUrl = await this.getDirectStreamUrl(videoId, backendEndpoint);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
url: streamUrl || url, // Fallback to original URL
|
|
145
|
+
type: 'youtube',
|
|
146
|
+
title: metadata.title,
|
|
147
|
+
thumbnail: metadata.thumbnail,
|
|
148
|
+
duration: metadata.duration,
|
|
149
|
+
videoId: videoId,
|
|
150
|
+
isYouTube: true,
|
|
151
|
+
metadata: {
|
|
152
|
+
source: 'youtube',
|
|
153
|
+
videoId: videoId
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default YouTubeExtractor;
|