unified-video-framework 1.4.241 → 1.4.243
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/web/dist/WebPlayer.d.ts +13 -0
- package/packages/web/dist/WebPlayer.d.ts.map +1 -1
- package/packages/web/dist/WebPlayer.js +240 -1
- package/packages/web/dist/WebPlayer.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 +312 -1
- package/packages/web/src/utils/YouTubeExtractor.ts +159 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface YouTubeVideoInfo {
|
|
2
|
+
videoId: string;
|
|
3
|
+
title: string;
|
|
4
|
+
duration: number;
|
|
5
|
+
thumbnail: string;
|
|
6
|
+
streamUrl: string;
|
|
7
|
+
format: 'mp4' | 'webm';
|
|
8
|
+
}
|
|
9
|
+
export declare class YouTubeExtractor {
|
|
10
|
+
private static readonly YOUTUBE_REGEX;
|
|
11
|
+
private static readonly YOUTUBE_NOEMBED_API;
|
|
12
|
+
private static readonly YOUTUBE_API_ENDPOINT;
|
|
13
|
+
static isYouTubeUrl(url: string): boolean;
|
|
14
|
+
static extractVideoId(url: string): string | null;
|
|
15
|
+
static getVideoMetadata(url: string): Promise<{
|
|
16
|
+
title: string;
|
|
17
|
+
thumbnail: string;
|
|
18
|
+
duration?: number;
|
|
19
|
+
}>;
|
|
20
|
+
static getEmbedUrl(videoId: string): string;
|
|
21
|
+
static getDirectStreamUrl(videoId: string, backendEndpoint?: string): Promise<string | null>;
|
|
22
|
+
private static getFallbackStreamUrl;
|
|
23
|
+
static prepareYouTubeSource(url: string, backendEndpoint?: string): Promise<{
|
|
24
|
+
url: string;
|
|
25
|
+
type: string;
|
|
26
|
+
title: string;
|
|
27
|
+
thumbnail: string;
|
|
28
|
+
duration: number | undefined;
|
|
29
|
+
videoId: string;
|
|
30
|
+
isYouTube: boolean;
|
|
31
|
+
metadata: {
|
|
32
|
+
source: string;
|
|
33
|
+
videoId: string;
|
|
34
|
+
};
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export default YouTubeExtractor;
|
|
38
|
+
//# sourceMappingURL=YouTubeExtractor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"YouTubeExtractor.d.ts","sourceRoot":"","sources":["../../src/utils/YouTubeExtractor.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;CACxB;AAED,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAyG;IAC9I,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAoC;IAC/E,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAyC;IAKrF,MAAM,CAAC,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAOzC,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;WASpC,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAClD,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IAiCF,MAAM,CAAC,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;WAa9B,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IA+BlG,OAAO,CAAC,MAAM,CAAC,oBAAoB;WAUtB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM;;;;;;;;;;;;;CA2BxE;AAED,eAAe,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export class YouTubeExtractor {
|
|
2
|
+
static isYouTubeUrl(url) {
|
|
3
|
+
return this.YOUTUBE_REGEX.test(url);
|
|
4
|
+
}
|
|
5
|
+
static extractVideoId(url) {
|
|
6
|
+
const match = url.match(this.YOUTUBE_REGEX);
|
|
7
|
+
return match ? match[1] : null;
|
|
8
|
+
}
|
|
9
|
+
static async getVideoMetadata(url) {
|
|
10
|
+
try {
|
|
11
|
+
const apiUrl = `${this.YOUTUBE_NOEMBED_API}${encodeURIComponent(url)}`;
|
|
12
|
+
const response = await fetch(apiUrl);
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new Error('Failed to fetch YouTube metadata');
|
|
15
|
+
}
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
return {
|
|
18
|
+
title: data.title || 'YouTube Video',
|
|
19
|
+
thumbnail: data.thumbnail_url || `https://img.youtube.com/vi/${this.extractVideoId(url)}/maxresdefault.jpg`,
|
|
20
|
+
duration: data.duration || undefined
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.warn('Failed to fetch YouTube metadata:', error);
|
|
25
|
+
const videoId = this.extractVideoId(url);
|
|
26
|
+
return {
|
|
27
|
+
title: 'YouTube Video',
|
|
28
|
+
thumbnail: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
|
29
|
+
duration: undefined
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
static getEmbedUrl(videoId) {
|
|
34
|
+
return `https://www.youtube.com/embed/${videoId}?modestbranding=1&rel=0&controls=0`;
|
|
35
|
+
}
|
|
36
|
+
static async getDirectStreamUrl(videoId, backendEndpoint) {
|
|
37
|
+
if (!backendEndpoint) {
|
|
38
|
+
console.warn('No backend endpoint provided for YouTube video extraction. Using fallback method.');
|
|
39
|
+
return this.getFallbackStreamUrl(videoId);
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(backendEndpoint, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/json'
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ videoId })
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error('Backend failed to extract stream');
|
|
51
|
+
}
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
return data.streamUrl || null;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('Failed to get direct stream URL:', error);
|
|
57
|
+
return this.getFallbackStreamUrl(videoId);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
static getFallbackStreamUrl(videoId) {
|
|
61
|
+
return `https://www.youtube.com/watch?v=${videoId}`;
|
|
62
|
+
}
|
|
63
|
+
static async prepareYouTubeSource(url, backendEndpoint) {
|
|
64
|
+
const videoId = this.extractVideoId(url);
|
|
65
|
+
if (!videoId) {
|
|
66
|
+
throw new Error('Invalid YouTube URL');
|
|
67
|
+
}
|
|
68
|
+
const metadata = await this.getVideoMetadata(url);
|
|
69
|
+
const streamUrl = await this.getDirectStreamUrl(videoId, backendEndpoint);
|
|
70
|
+
return {
|
|
71
|
+
url: streamUrl || url,
|
|
72
|
+
type: 'youtube',
|
|
73
|
+
title: metadata.title,
|
|
74
|
+
thumbnail: metadata.thumbnail,
|
|
75
|
+
duration: metadata.duration,
|
|
76
|
+
videoId: videoId,
|
|
77
|
+
isYouTube: true,
|
|
78
|
+
metadata: {
|
|
79
|
+
source: 'youtube',
|
|
80
|
+
videoId: videoId
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
YouTubeExtractor.YOUTUBE_REGEX = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|v\/)?([a-zA-Z0-9_-]{11})/;
|
|
86
|
+
YouTubeExtractor.YOUTUBE_NOEMBED_API = 'https://noembed.com/embed?url=';
|
|
87
|
+
YouTubeExtractor.YOUTUBE_API_ENDPOINT = 'https://www.youtube.com/oembed?url=';
|
|
88
|
+
export default YouTubeExtractor;
|
|
89
|
+
//# sourceMappingURL=YouTubeExtractor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"YouTubeExtractor.js","sourceRoot":"","sources":["../../src/utils/YouTubeExtractor.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
}
|
|
@@ -680,6 +682,9 @@ export class WebPlayer extends BasePlayer {
|
|
|
680
682
|
case 'dash':
|
|
681
683
|
await this.loadDASH(url);
|
|
682
684
|
break;
|
|
685
|
+
case 'youtube':
|
|
686
|
+
await this.loadYouTube(url, source);
|
|
687
|
+
break;
|
|
683
688
|
default:
|
|
684
689
|
await this.loadNative(url);
|
|
685
690
|
}
|
|
@@ -869,7 +874,7 @@ export class WebPlayer extends BasePlayer {
|
|
|
869
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">
|
|
870
875
|
<rect x="10" y="16" width="20" height="16" rx="2" />
|
|
871
876
|
<polygon points="34,20 42,16 42,32 34,28" />
|
|
872
|
-
<line x1="
|
|
877
|
+
<line x1="5" y1="8" x2="38" y2="40" stroke="currentColor" stroke-width="2"/>
|
|
873
878
|
</svg>
|
|
874
879
|
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">Video Unavailable</div>
|
|
875
880
|
<div style="font-size: 14px; opacity: 0.9;">This video cannot be played at the moment.</div>
|
|
@@ -899,6 +904,12 @@ export class WebPlayer extends BasePlayer {
|
|
|
899
904
|
}
|
|
900
905
|
|
|
901
906
|
const url = source.url.toLowerCase();
|
|
907
|
+
|
|
908
|
+
// Check for YouTube URLs
|
|
909
|
+
if (YouTubeExtractor.isYouTubeUrl(url)) {
|
|
910
|
+
return 'youtube';
|
|
911
|
+
}
|
|
912
|
+
|
|
902
913
|
if (url.includes('.m3u8')) return 'hls';
|
|
903
914
|
if (url.includes('.mpd')) return 'dash';
|
|
904
915
|
if (url.includes('.mp4')) return 'mp4';
|
|
@@ -1072,6 +1083,265 @@ export class WebPlayer extends BasePlayer {
|
|
|
1072
1083
|
this.video.load();
|
|
1073
1084
|
}
|
|
1074
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
|
+
// Create YouTube iframe player with custom controls integration
|
|
1120
|
+
await this.createYouTubePlayer(videoId);
|
|
1121
|
+
|
|
1122
|
+
this.debugLog('✅ YouTube video loaded successfully');
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
this.debugError('Failed to load YouTube video:', error);
|
|
1125
|
+
throw new Error(`YouTube video loading failed: ${error}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
private youtubePlayer: any = null;
|
|
1130
|
+
private youtubePlayerReady: boolean = false;
|
|
1131
|
+
private youtubeIframe: HTMLIFrameElement | null = null;
|
|
1132
|
+
|
|
1133
|
+
private async createYouTubePlayer(videoId: string): Promise<void> {
|
|
1134
|
+
const container = this.playerWrapper || this.video?.parentElement;
|
|
1135
|
+
if (!container) {
|
|
1136
|
+
throw new Error('No container found for YouTube player');
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Hide the regular video element
|
|
1140
|
+
if (this.video) {
|
|
1141
|
+
this.video.style.display = 'none';
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Create iframe container
|
|
1145
|
+
const iframeContainer = document.createElement('div');
|
|
1146
|
+
iframeContainer.id = `youtube-player-${videoId}`;
|
|
1147
|
+
iframeContainer.style.cssText = `
|
|
1148
|
+
position: absolute;
|
|
1149
|
+
top: 0;
|
|
1150
|
+
left: 0;
|
|
1151
|
+
width: 100%;
|
|
1152
|
+
height: 100%;
|
|
1153
|
+
z-index: 1;
|
|
1154
|
+
`;
|
|
1155
|
+
|
|
1156
|
+
// Remove existing YouTube player if any
|
|
1157
|
+
const existingPlayer = container.querySelector(`#youtube-player-${videoId}`);
|
|
1158
|
+
if (existingPlayer) {
|
|
1159
|
+
existingPlayer.remove();
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
container.appendChild(iframeContainer);
|
|
1163
|
+
|
|
1164
|
+
// Load YouTube IFrame API if not loaded
|
|
1165
|
+
if (!window.YT) {
|
|
1166
|
+
await this.loadYouTubeAPI();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Wait for API to be ready
|
|
1170
|
+
await this.waitForYouTubeAPI();
|
|
1171
|
+
|
|
1172
|
+
// Create YouTube player
|
|
1173
|
+
this.youtubePlayer = new window.YT.Player(iframeContainer.id, {
|
|
1174
|
+
videoId: videoId,
|
|
1175
|
+
width: '100%',
|
|
1176
|
+
height: '100%',
|
|
1177
|
+
playerVars: {
|
|
1178
|
+
controls: 0, // Hide YouTube controls
|
|
1179
|
+
disablekb: 1, // Disable keyboard controls
|
|
1180
|
+
fs: 0, // Hide fullscreen button
|
|
1181
|
+
iv_load_policy: 3, // Hide annotations
|
|
1182
|
+
modestbranding: 1, // Minimal YouTube branding
|
|
1183
|
+
rel: 0, // Don't show related videos
|
|
1184
|
+
showinfo: 0, // Hide video info
|
|
1185
|
+
autoplay: this.config.autoPlay ? 1 : 0,
|
|
1186
|
+
mute: this.config.muted ? 1 : 0
|
|
1187
|
+
},
|
|
1188
|
+
events: {
|
|
1189
|
+
onReady: () => this.onYouTubePlayerReady(),
|
|
1190
|
+
onStateChange: (event: any) => this.onYouTubePlayerStateChange(event),
|
|
1191
|
+
onError: (event: any) => this.onYouTubePlayerError(event)
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
this.debugLog('YouTube player created');
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private async loadYouTubeAPI(): Promise<void> {
|
|
1199
|
+
return new Promise((resolve) => {
|
|
1200
|
+
if (window.YT) {
|
|
1201
|
+
resolve();
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Set up the callback for when API loads
|
|
1206
|
+
(window as any).onYouTubeIframeAPIReady = () => {
|
|
1207
|
+
this.debugLog('YouTube IFrame API loaded');
|
|
1208
|
+
resolve();
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
// Load the API script
|
|
1212
|
+
const script = document.createElement('script');
|
|
1213
|
+
script.src = 'https://www.youtube.com/iframe_api';
|
|
1214
|
+
script.async = true;
|
|
1215
|
+
document.body.appendChild(script);
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
private async waitForYouTubeAPI(): Promise<void> {
|
|
1220
|
+
return new Promise((resolve) => {
|
|
1221
|
+
const checkAPI = () => {
|
|
1222
|
+
if (window.YT && window.YT.Player) {
|
|
1223
|
+
resolve();
|
|
1224
|
+
} else {
|
|
1225
|
+
setTimeout(checkAPI, 100);
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
checkAPI();
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private onYouTubePlayerReady(): void {
|
|
1233
|
+
this.youtubePlayerReady = true;
|
|
1234
|
+
this.debugLog('YouTube player ready');
|
|
1235
|
+
|
|
1236
|
+
// Set initial volume
|
|
1237
|
+
if (this.youtubePlayer) {
|
|
1238
|
+
const volume = this.config.volume ? this.config.volume * 100 : 100;
|
|
1239
|
+
this.youtubePlayer.setVolume(volume);
|
|
1240
|
+
|
|
1241
|
+
if (this.config.muted) {
|
|
1242
|
+
this.youtubePlayer.mute();
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Start time tracking
|
|
1247
|
+
this.startYouTubeTimeTracking();
|
|
1248
|
+
|
|
1249
|
+
this.emit('onReady');
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
private onYouTubePlayerStateChange(event: any): void {
|
|
1253
|
+
const state = event.data;
|
|
1254
|
+
|
|
1255
|
+
switch (state) {
|
|
1256
|
+
case window.YT.PlayerState.PLAYING:
|
|
1257
|
+
this.state.isPlaying = true;
|
|
1258
|
+
this.state.isPaused = false;
|
|
1259
|
+
this.state.isBuffering = false;
|
|
1260
|
+
this.emit('onPlay');
|
|
1261
|
+
break;
|
|
1262
|
+
|
|
1263
|
+
case window.YT.PlayerState.PAUSED:
|
|
1264
|
+
this.state.isPlaying = false;
|
|
1265
|
+
this.state.isPaused = true;
|
|
1266
|
+
this.state.isBuffering = false;
|
|
1267
|
+
this.emit('onPause');
|
|
1268
|
+
break;
|
|
1269
|
+
|
|
1270
|
+
case window.YT.PlayerState.BUFFERING:
|
|
1271
|
+
this.state.isBuffering = true;
|
|
1272
|
+
this.emit('onBuffering', true);
|
|
1273
|
+
break;
|
|
1274
|
+
|
|
1275
|
+
case window.YT.PlayerState.ENDED:
|
|
1276
|
+
this.state.isPlaying = false;
|
|
1277
|
+
this.state.isPaused = true;
|
|
1278
|
+
this.state.isEnded = true;
|
|
1279
|
+
this.emit('onEnded');
|
|
1280
|
+
break;
|
|
1281
|
+
|
|
1282
|
+
case window.YT.PlayerState.CUED:
|
|
1283
|
+
this.state.duration = this.youtubePlayer.getDuration();
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
private onYouTubePlayerError(event: any): void {
|
|
1289
|
+
const errorCode = event.data;
|
|
1290
|
+
let errorMessage = 'YouTube player error';
|
|
1291
|
+
|
|
1292
|
+
switch (errorCode) {
|
|
1293
|
+
case 2:
|
|
1294
|
+
errorMessage = 'Invalid video ID';
|
|
1295
|
+
break;
|
|
1296
|
+
case 5:
|
|
1297
|
+
errorMessage = 'HTML5 player error';
|
|
1298
|
+
break;
|
|
1299
|
+
case 100:
|
|
1300
|
+
errorMessage = 'Video not found or private';
|
|
1301
|
+
break;
|
|
1302
|
+
case 101:
|
|
1303
|
+
case 150:
|
|
1304
|
+
errorMessage = 'Video cannot be embedded';
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
this.handleError({
|
|
1309
|
+
code: 'YOUTUBE_ERROR',
|
|
1310
|
+
message: errorMessage,
|
|
1311
|
+
type: 'media',
|
|
1312
|
+
fatal: true,
|
|
1313
|
+
details: { errorCode }
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
private youtubeTimeTrackingInterval: NodeJS.Timeout | null = null;
|
|
1318
|
+
|
|
1319
|
+
private startYouTubeTimeTracking(): void {
|
|
1320
|
+
if (this.youtubeTimeTrackingInterval) {
|
|
1321
|
+
clearInterval(this.youtubeTimeTrackingInterval);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
this.youtubeTimeTrackingInterval = setInterval(() => {
|
|
1325
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
1326
|
+
try {
|
|
1327
|
+
const currentTime = this.youtubePlayer.getCurrentTime();
|
|
1328
|
+
const duration = this.youtubePlayer.getDuration();
|
|
1329
|
+
const buffered = this.youtubePlayer.getVideoLoadedFraction() * 100;
|
|
1330
|
+
|
|
1331
|
+
this.state.currentTime = currentTime || 0;
|
|
1332
|
+
this.state.duration = duration || 0;
|
|
1333
|
+
this.state.bufferedPercentage = buffered || 0;
|
|
1334
|
+
|
|
1335
|
+
this.emit('onTimeUpdate', this.state.currentTime);
|
|
1336
|
+
this.emit('onProgress', this.state.bufferedPercentage);
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
// Ignore errors during tracking
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}, 250); // Update every 250ms
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
|
|
1075
1345
|
protected loadScript(src: string): Promise<void> {
|
|
1076
1346
|
return new Promise((resolve, reject) => {
|
|
1077
1347
|
const script = document.createElement('script');
|
|
@@ -1556,6 +1826,12 @@ export class WebPlayer extends BasePlayer {
|
|
|
1556
1826
|
}
|
|
1557
1827
|
|
|
1558
1828
|
async play(): Promise<void> {
|
|
1829
|
+
// Handle YouTube player
|
|
1830
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
1831
|
+
this.youtubePlayer.playVideo();
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1559
1835
|
if (!this.video) throw new Error('Video element not initialized');
|
|
1560
1836
|
|
|
1561
1837
|
// Prevent playback when in fallback poster mode (no valid sources)
|
|
@@ -1621,6 +1897,12 @@ export class WebPlayer extends BasePlayer {
|
|
|
1621
1897
|
}
|
|
1622
1898
|
|
|
1623
1899
|
pause(): void {
|
|
1900
|
+
// Handle YouTube player
|
|
1901
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
1902
|
+
this.youtubePlayer.pauseVideo();
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1624
1906
|
if (!this.video) return;
|
|
1625
1907
|
|
|
1626
1908
|
const now = Date.now();
|
|
@@ -1688,6 +1970,14 @@ export class WebPlayer extends BasePlayer {
|
|
|
1688
1970
|
}
|
|
1689
1971
|
|
|
1690
1972
|
seek(time: number): void {
|
|
1973
|
+
// Handle YouTube player
|
|
1974
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
1975
|
+
this.youtubePlayer.seekTo(time, true);
|
|
1976
|
+
this.emit('onSeeking');
|
|
1977
|
+
setTimeout(() => this.emit('onSeeked'), 500);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1691
1981
|
if (!this.video) return;
|
|
1692
1982
|
|
|
1693
1983
|
// Validate input time
|
|
@@ -1714,18 +2004,34 @@ export class WebPlayer extends BasePlayer {
|
|
|
1714
2004
|
}
|
|
1715
2005
|
|
|
1716
2006
|
setVolume(level: number): void {
|
|
2007
|
+
// Handle YouTube player
|
|
2008
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
2009
|
+
const volumePercent = level * 100;
|
|
2010
|
+
this.youtubePlayer.setVolume(volumePercent);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
1717
2013
|
if (!this.video) return;
|
|
1718
2014
|
this.video.volume = Math.max(0, Math.min(1, level));
|
|
1719
2015
|
super.setVolume(level);
|
|
1720
2016
|
}
|
|
1721
2017
|
|
|
1722
2018
|
mute(): void {
|
|
2019
|
+
// Handle YouTube player
|
|
2020
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
2021
|
+
this.youtubePlayer.mute();
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1723
2024
|
if (!this.video) return;
|
|
1724
2025
|
this.video.muted = true;
|
|
1725
2026
|
super.mute();
|
|
1726
2027
|
}
|
|
1727
2028
|
|
|
1728
2029
|
unmute(): void {
|
|
2030
|
+
// Handle YouTube player
|
|
2031
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
2032
|
+
this.youtubePlayer.unMute();
|
|
2033
|
+
}
|
|
2034
|
+
|
|
1729
2035
|
if (!this.video) return;
|
|
1730
2036
|
this.video.muted = false;
|
|
1731
2037
|
super.unmute();
|
|
@@ -1733,6 +2039,11 @@ export class WebPlayer extends BasePlayer {
|
|
|
1733
2039
|
|
|
1734
2040
|
|
|
1735
2041
|
getCurrentTime(): number {
|
|
2042
|
+
// Handle YouTube player
|
|
2043
|
+
if (this.youtubePlayer && this.youtubePlayerReady) {
|
|
2044
|
+
return this.youtubePlayer.getCurrentTime();
|
|
2045
|
+
}
|
|
2046
|
+
|
|
1736
2047
|
if (this.video && typeof this.video.currentTime === 'number') {
|
|
1737
2048
|
return this.video.currentTime;
|
|
1738
2049
|
}
|
|
@@ -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;
|