unified-video-framework 1.4.150 → 1.4.153
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/chapter-manager.d.ts +39 -0
- package/packages/core/dist/chapter-manager.d.ts.map +1 -0
- package/packages/core/dist/chapter-manager.js +173 -0
- package/packages/core/dist/chapter-manager.js.map +1 -0
- package/packages/core/dist/index.d.ts +2 -0
- package/packages/core/dist/index.d.ts.map +1 -1
- package/packages/core/dist/index.js +1 -0
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/interfaces/IVideoPlayer.d.ts +10 -0
- package/packages/core/dist/interfaces/IVideoPlayer.d.ts.map +1 -1
- package/packages/core/dist/interfaces.d.ts +33 -1
- package/packages/core/dist/interfaces.d.ts.map +1 -1
- package/packages/core/package.json +2 -2
- package/packages/core/src/chapter-manager.ts +290 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/interfaces/IVideoPlayer.ts +11 -0
- package/packages/core/src/interfaces.ts +47 -1
- package/packages/web/dist/WebPlayer.d.ts +24 -1
- package/packages/web/dist/WebPlayer.d.ts.map +1 -1
- package/packages/web/dist/WebPlayer.js +492 -9
- package/packages/web/dist/WebPlayer.js.map +1 -1
- package/packages/web/dist/chapters/ChapterManager.d.ts +38 -0
- package/packages/web/dist/chapters/ChapterManager.d.ts.map +1 -0
- package/packages/web/dist/chapters/ChapterManager.js +291 -0
- package/packages/web/dist/chapters/ChapterManager.js.map +1 -0
- package/packages/web/dist/chapters/SkipButtonController.d.ts +31 -0
- package/packages/web/dist/chapters/SkipButtonController.d.ts.map +1 -0
- package/packages/web/dist/chapters/SkipButtonController.js +213 -0
- package/packages/web/dist/chapters/SkipButtonController.js.map +1 -0
- package/packages/web/dist/chapters/UserPreferencesManager.d.ts +25 -0
- package/packages/web/dist/chapters/UserPreferencesManager.d.ts.map +1 -0
- package/packages/web/dist/chapters/UserPreferencesManager.js +232 -0
- package/packages/web/dist/chapters/UserPreferencesManager.js.map +1 -0
- package/packages/web/dist/chapters/index.d.ts +12 -0
- package/packages/web/dist/chapters/index.d.ts.map +1 -0
- package/packages/web/dist/chapters/index.js +8 -0
- package/packages/web/dist/chapters/index.js.map +1 -0
- package/packages/web/dist/chapters/types/ChapterTypes.d.ts +98 -0
- package/packages/web/dist/chapters/types/ChapterTypes.d.ts.map +1 -0
- package/packages/web/dist/chapters/types/ChapterTypes.js +31 -0
- package/packages/web/dist/chapters/types/ChapterTypes.js.map +1 -0
- package/packages/web/dist/index.d.ts +1 -1
- package/packages/web/dist/index.d.ts.map +1 -1
- package/packages/web/dist/index.js +1 -1
- package/packages/web/dist/index.js.map +1 -1
- package/packages/web/dist/paywall/EmailAuthController.d.ts +1 -1
- package/packages/web/dist/paywall/EmailAuthController.d.ts.map +1 -1
- package/packages/web/dist/paywall/PaywallController.d.ts +1 -1
- package/packages/web/dist/paywall/PaywallController.d.ts.map +1 -1
- package/packages/web/dist/react/WebPlayerView.d.ts +2 -2
- package/packages/web/dist/react/WebPlayerView.d.ts.map +1 -1
- package/packages/web/dist/react/WebPlayerViewWithEPG.d.ts +2 -2
- package/packages/web/dist/react/WebPlayerViewWithEPG.d.ts.map +1 -1
- package/packages/web/dist/react/components/ChapterProgress.d.ts +22 -0
- package/packages/web/dist/react/components/ChapterProgress.d.ts.map +1 -0
- package/packages/web/dist/react/components/ChapterProgress.js +101 -0
- package/packages/web/dist/react/components/ChapterProgress.js.map +1 -0
- package/packages/web/dist/react/components/SkipButton.d.ts +18 -0
- package/packages/web/dist/react/components/SkipButton.d.ts.map +1 -0
- package/packages/web/dist/react/components/SkipButton.js +156 -0
- package/packages/web/dist/react/components/SkipButton.js.map +1 -0
- package/packages/web/dist/react/hooks/useChapters.d.ts +29 -0
- package/packages/web/dist/react/hooks/useChapters.d.ts.map +1 -0
- package/packages/web/dist/react/hooks/useChapters.js +158 -0
- package/packages/web/dist/react/hooks/useChapters.js.map +1 -0
- package/packages/web/package.json +3 -3
- package/packages/web/src/SecureVideoPlayer.ts +1 -1
- package/packages/web/src/WebPlayer.ts +610 -11
- package/packages/web/src/__tests__/WebPlayer.test.ts +1 -1
- package/packages/web/src/__tests__/epg-integration.test.ts +1 -1
- package/packages/web/src/chapters/ChapterManager.ts +464 -0
- package/packages/web/src/chapters/SkipButtonController.ts +353 -0
- package/packages/web/src/chapters/UserPreferencesManager.ts +324 -0
- package/packages/web/src/chapters/index.ts +34 -0
- package/packages/web/src/chapters/types/ChapterTypes.ts +236 -0
- package/packages/web/src/index.ts +1 -1
- package/packages/web/src/paywall/EmailAuthController.ts +1 -1
- package/packages/web/src/paywall/PaywallController.ts +1 -1
- package/packages/web/src/react/EPG.ts +1 -1
- package/packages/web/src/react/WebPlayerView.tsx +2 -2
- package/packages/web/src/react/WebPlayerViewWithEPG.tsx +3 -3
- package/packages/web/src/react/components/ChapterProgress.tsx +207 -0
- package/packages/web/src/react/components/EPGNavigationControls.tsx +1 -1
- package/packages/web/src/react/components/EPGOverlay-improved-positioning.tsx +1 -1
- package/packages/web/src/react/components/EPGOverlay.tsx +1 -1
- package/packages/web/src/react/components/EPGProgramGrid.tsx +1 -1
- package/packages/web/src/react/components/EPGTimelineHeader.tsx +1 -1
- package/packages/web/src/react/components/SkipButton.tsx +278 -0
- package/packages/web/src/react/hooks/useChapters.ts +308 -0
- package/packages/web/src/react/types/EPGTypes.ts +1 -1
- package/packages/web/src/react/utils/EPGUtils.ts +1 -1
- package/packages/web/src/test/epg-test.ts +1 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React component for skip button
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
VideoSegment,
|
|
8
|
+
SkipButtonPosition,
|
|
9
|
+
DEFAULT_SKIP_LABELS
|
|
10
|
+
} from '../../chapters/types/ChapterTypes';
|
|
11
|
+
|
|
12
|
+
export interface SkipButtonProps {
|
|
13
|
+
/** Current segment to show skip button for */
|
|
14
|
+
segment: VideoSegment | null;
|
|
15
|
+
|
|
16
|
+
/** Whether button is visible */
|
|
17
|
+
visible?: boolean;
|
|
18
|
+
|
|
19
|
+
/** Button position */
|
|
20
|
+
position?: SkipButtonPosition;
|
|
21
|
+
|
|
22
|
+
/** Auto-hide delay in milliseconds */
|
|
23
|
+
autoHideDelay?: number;
|
|
24
|
+
|
|
25
|
+
/** Custom skip button text */
|
|
26
|
+
skipLabel?: string;
|
|
27
|
+
|
|
28
|
+
/** Callback when skip button is clicked */
|
|
29
|
+
onSkip?: (segment: VideoSegment) => void;
|
|
30
|
+
|
|
31
|
+
/** Callback when button is shown */
|
|
32
|
+
onShow?: (segment: VideoSegment) => void;
|
|
33
|
+
|
|
34
|
+
/** Callback when button is hidden */
|
|
35
|
+
onHide?: (segment: VideoSegment, reason: string) => void;
|
|
36
|
+
|
|
37
|
+
/** Custom CSS class name */
|
|
38
|
+
className?: string;
|
|
39
|
+
|
|
40
|
+
/** Custom styles */
|
|
41
|
+
style?: React.CSSProperties;
|
|
42
|
+
|
|
43
|
+
/** Enable auto-skip countdown */
|
|
44
|
+
enableAutoSkip?: boolean;
|
|
45
|
+
|
|
46
|
+
/** Auto-skip delay in seconds */
|
|
47
|
+
autoSkipDelay?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const SkipButton: React.FC<SkipButtonProps> = ({
|
|
51
|
+
segment,
|
|
52
|
+
visible = false,
|
|
53
|
+
position = 'bottom-right',
|
|
54
|
+
autoHideDelay = 5000,
|
|
55
|
+
skipLabel,
|
|
56
|
+
onSkip,
|
|
57
|
+
onShow,
|
|
58
|
+
onHide,
|
|
59
|
+
className = '',
|
|
60
|
+
style = {},
|
|
61
|
+
enableAutoSkip = false,
|
|
62
|
+
autoSkipDelay = 10
|
|
63
|
+
}) => {
|
|
64
|
+
// State
|
|
65
|
+
const [isVisible, setIsVisible] = useState(visible);
|
|
66
|
+
const [isAutoSkip, setIsAutoSkip] = useState(false);
|
|
67
|
+
const [countdown, setCountdown] = useState<number | null>(null);
|
|
68
|
+
|
|
69
|
+
// Refs
|
|
70
|
+
const autoHideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
71
|
+
const autoSkipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
72
|
+
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
73
|
+
const previousSegmentRef = useRef<VideoSegment | null>(null);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clear all timeouts
|
|
77
|
+
*/
|
|
78
|
+
const clearTimeouts = () => {
|
|
79
|
+
if (autoHideTimeoutRef.current) {
|
|
80
|
+
clearTimeout(autoHideTimeoutRef.current);
|
|
81
|
+
autoHideTimeoutRef.current = null;
|
|
82
|
+
}
|
|
83
|
+
if (autoSkipTimeoutRef.current) {
|
|
84
|
+
clearTimeout(autoSkipTimeoutRef.current);
|
|
85
|
+
autoSkipTimeoutRef.current = null;
|
|
86
|
+
}
|
|
87
|
+
if (countdownIntervalRef.current) {
|
|
88
|
+
clearInterval(countdownIntervalRef.current);
|
|
89
|
+
countdownIntervalRef.current = null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start auto-skip countdown
|
|
95
|
+
*/
|
|
96
|
+
const startAutoSkip = (segment: VideoSegment, delay: number) => {
|
|
97
|
+
setIsAutoSkip(true);
|
|
98
|
+
setCountdown(delay);
|
|
99
|
+
|
|
100
|
+
// Start countdown interval
|
|
101
|
+
countdownIntervalRef.current = setInterval(() => {
|
|
102
|
+
setCountdown((prev) => {
|
|
103
|
+
if (prev === null || prev <= 1) {
|
|
104
|
+
// Auto-skip triggered
|
|
105
|
+
clearTimeouts();
|
|
106
|
+
onSkip?.(segment);
|
|
107
|
+
handleHide('timeout');
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return prev - 1;
|
|
111
|
+
});
|
|
112
|
+
}, 1000);
|
|
113
|
+
|
|
114
|
+
// Set backup timeout
|
|
115
|
+
autoSkipTimeoutRef.current = setTimeout(() => {
|
|
116
|
+
clearTimeouts();
|
|
117
|
+
onSkip?.(segment);
|
|
118
|
+
handleHide('timeout');
|
|
119
|
+
}, delay * 1000);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Handle button click
|
|
124
|
+
*/
|
|
125
|
+
const handleSkip = () => {
|
|
126
|
+
if (!segment) return;
|
|
127
|
+
|
|
128
|
+
clearTimeouts();
|
|
129
|
+
onSkip?.(segment);
|
|
130
|
+
handleHide('user-action');
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Handle button show
|
|
135
|
+
*/
|
|
136
|
+
const handleShow = () => {
|
|
137
|
+
if (!segment) return;
|
|
138
|
+
|
|
139
|
+
setIsVisible(true);
|
|
140
|
+
onShow?.(segment);
|
|
141
|
+
|
|
142
|
+
// Start auto-hide timer
|
|
143
|
+
if (autoHideDelay > 0) {
|
|
144
|
+
autoHideTimeoutRef.current = setTimeout(() => {
|
|
145
|
+
handleHide('timeout');
|
|
146
|
+
}, autoHideDelay);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Start auto-skip if enabled
|
|
150
|
+
if (enableAutoSkip && segment.autoSkip && segment.autoSkipDelay) {
|
|
151
|
+
startAutoSkip(segment, segment.autoSkipDelay);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Handle button hide
|
|
157
|
+
*/
|
|
158
|
+
const handleHide = (reason: string = 'manual') => {
|
|
159
|
+
if (!segment) return;
|
|
160
|
+
|
|
161
|
+
clearTimeouts();
|
|
162
|
+
setIsVisible(false);
|
|
163
|
+
setIsAutoSkip(false);
|
|
164
|
+
setCountdown(null);
|
|
165
|
+
onHide?.(segment, reason);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Effect to handle visibility changes
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (visible && segment && segment !== previousSegmentRef.current) {
|
|
171
|
+
previousSegmentRef.current = segment;
|
|
172
|
+
handleShow();
|
|
173
|
+
} else if (!visible || !segment) {
|
|
174
|
+
if (previousSegmentRef.current) {
|
|
175
|
+
handleHide('segment-end');
|
|
176
|
+
}
|
|
177
|
+
previousSegmentRef.current = null;
|
|
178
|
+
}
|
|
179
|
+
}, [visible, segment]);
|
|
180
|
+
|
|
181
|
+
// Cleanup on unmount
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
return () => {
|
|
184
|
+
clearTimeouts();
|
|
185
|
+
};
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
// Don't render if no segment or not visible
|
|
189
|
+
if (!segment || !isVisible) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Get skip label
|
|
194
|
+
const buttonText = skipLabel || segment.skipLabel || DEFAULT_SKIP_LABELS[segment.type];
|
|
195
|
+
const displayText = isAutoSkip && countdown !== null
|
|
196
|
+
? `${buttonText} (${countdown})`
|
|
197
|
+
: buttonText;
|
|
198
|
+
|
|
199
|
+
// Get position classes
|
|
200
|
+
const positionClass = `uvf-skip-button-${position}`;
|
|
201
|
+
const segmentClass = `uvf-skip-${segment.type}`;
|
|
202
|
+
const autoSkipClass = isAutoSkip ? 'auto-skip' : '';
|
|
203
|
+
const countdownClass = isAutoSkip && countdown !== null ? 'countdown' : '';
|
|
204
|
+
|
|
205
|
+
// Combine classes
|
|
206
|
+
const buttonClasses = [
|
|
207
|
+
'uvf-skip-button',
|
|
208
|
+
'visible',
|
|
209
|
+
positionClass,
|
|
210
|
+
segmentClass,
|
|
211
|
+
autoSkipClass,
|
|
212
|
+
countdownClass,
|
|
213
|
+
className
|
|
214
|
+
].filter(Boolean).join(' ');
|
|
215
|
+
|
|
216
|
+
// Default styles based on position
|
|
217
|
+
const defaultStyles: React.CSSProperties = {
|
|
218
|
+
position: 'absolute',
|
|
219
|
+
zIndex: 1000,
|
|
220
|
+
...style
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
switch (position) {
|
|
224
|
+
case 'bottom-right':
|
|
225
|
+
Object.assign(defaultStyles, {
|
|
226
|
+
bottom: '100px',
|
|
227
|
+
right: '30px'
|
|
228
|
+
});
|
|
229
|
+
break;
|
|
230
|
+
case 'bottom-left':
|
|
231
|
+
Object.assign(defaultStyles, {
|
|
232
|
+
bottom: '100px',
|
|
233
|
+
left: '30px'
|
|
234
|
+
});
|
|
235
|
+
break;
|
|
236
|
+
case 'top-right':
|
|
237
|
+
Object.assign(defaultStyles, {
|
|
238
|
+
top: '30px',
|
|
239
|
+
right: '30px'
|
|
240
|
+
});
|
|
241
|
+
break;
|
|
242
|
+
case 'top-left':
|
|
243
|
+
Object.assign(defaultStyles, {
|
|
244
|
+
top: '30px',
|
|
245
|
+
left: '30px'
|
|
246
|
+
});
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
className={buttonClasses}
|
|
254
|
+
style={defaultStyles}
|
|
255
|
+
onClick={handleSkip}
|
|
256
|
+
aria-label={`${buttonText} - ${segment.title || segment.type}`}
|
|
257
|
+
>
|
|
258
|
+
{displayText}
|
|
259
|
+
|
|
260
|
+
{/* Progress bar for auto-skip countdown */}
|
|
261
|
+
{isAutoSkip && countdown !== null && (
|
|
262
|
+
<div
|
|
263
|
+
className="uvf-skip-countdown-progress"
|
|
264
|
+
style={{
|
|
265
|
+
position: 'absolute',
|
|
266
|
+
bottom: 0,
|
|
267
|
+
left: 0,
|
|
268
|
+
height: '3px',
|
|
269
|
+
backgroundColor: 'currentColor',
|
|
270
|
+
width: `${((autoSkipDelay - countdown) / autoSkipDelay) * 100}%`,
|
|
271
|
+
transition: 'width 1s linear',
|
|
272
|
+
borderRadius: '0 0 6px 6px'
|
|
273
|
+
}}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
</button>
|
|
277
|
+
);
|
|
278
|
+
};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hook for video chapters and skip functionality
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
VideoSegment,
|
|
8
|
+
VideoChapters,
|
|
9
|
+
ChapterConfig,
|
|
10
|
+
ChapterEvents,
|
|
11
|
+
SegmentType
|
|
12
|
+
} from '../../chapters/types/ChapterTypes';
|
|
13
|
+
|
|
14
|
+
export interface UseChaptersOptions {
|
|
15
|
+
videoElement?: HTMLVideoElement | null;
|
|
16
|
+
chapters?: VideoChapters;
|
|
17
|
+
config?: ChapterConfig;
|
|
18
|
+
onSegmentEntered?: (segment: VideoSegment) => void;
|
|
19
|
+
onSegmentSkipped?: (fromSegment: VideoSegment, toSegment?: VideoSegment) => void;
|
|
20
|
+
onSkipButtonShown?: (segment: VideoSegment) => void;
|
|
21
|
+
onSkipButtonHidden?: (segment: VideoSegment) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseChaptersResult {
|
|
25
|
+
// State
|
|
26
|
+
currentSegment: VideoSegment | null;
|
|
27
|
+
chapters: VideoChapters | null;
|
|
28
|
+
isSkipButtonVisible: boolean;
|
|
29
|
+
|
|
30
|
+
// Actions
|
|
31
|
+
loadChapters: (chapters: VideoChapters) => Promise<void>;
|
|
32
|
+
skipToSegment: (segmentId: string) => void;
|
|
33
|
+
skipCurrentSegment: () => void;
|
|
34
|
+
|
|
35
|
+
// Queries
|
|
36
|
+
getSegmentsByType: (type: SegmentType) => VideoSegment[];
|
|
37
|
+
hasSegmentType: (type: SegmentType) => boolean;
|
|
38
|
+
getChapterMarkers: () => Array<{
|
|
39
|
+
position: number;
|
|
40
|
+
segment: VideoSegment;
|
|
41
|
+
color: string;
|
|
42
|
+
}>;
|
|
43
|
+
|
|
44
|
+
// Utils
|
|
45
|
+
formatTime: (seconds: number) => string;
|
|
46
|
+
isInSegment: (segmentId: string) => boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useChapters(options: UseChaptersOptions = {}): UseChaptersResult {
|
|
50
|
+
const {
|
|
51
|
+
videoElement,
|
|
52
|
+
chapters: initialChapters,
|
|
53
|
+
config = { enabled: true },
|
|
54
|
+
onSegmentEntered,
|
|
55
|
+
onSegmentSkipped,
|
|
56
|
+
onSkipButtonShown,
|
|
57
|
+
onSkipButtonHidden
|
|
58
|
+
} = options;
|
|
59
|
+
|
|
60
|
+
// State
|
|
61
|
+
const [currentSegment, setCurrentSegment] = useState<VideoSegment | null>(null);
|
|
62
|
+
const [chapters, setChapters] = useState<VideoChapters | null>(initialChapters || null);
|
|
63
|
+
const [isSkipButtonVisible, setIsSkipButtonVisible] = useState(false);
|
|
64
|
+
|
|
65
|
+
// Refs
|
|
66
|
+
const timeUpdateHandlerRef = useRef<(() => void) | null>(null);
|
|
67
|
+
const previousSegmentRef = useRef<VideoSegment | null>(null);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get current segment at given time
|
|
71
|
+
*/
|
|
72
|
+
const getCurrentSegment = useCallback((currentTime: number): VideoSegment | null => {
|
|
73
|
+
if (!chapters) return null;
|
|
74
|
+
|
|
75
|
+
return chapters.segments.find(segment =>
|
|
76
|
+
currentTime >= segment.startTime && currentTime < segment.endTime
|
|
77
|
+
) || null;
|
|
78
|
+
}, [chapters]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Handle time update
|
|
82
|
+
*/
|
|
83
|
+
const handleTimeUpdate = useCallback(() => {
|
|
84
|
+
if (!videoElement || !chapters) return;
|
|
85
|
+
|
|
86
|
+
const newSegment = getCurrentSegment(videoElement.currentTime);
|
|
87
|
+
|
|
88
|
+
if (newSegment !== currentSegment) {
|
|
89
|
+
// Segment changed
|
|
90
|
+
if (currentSegment) {
|
|
91
|
+
// Exiting current segment
|
|
92
|
+
setIsSkipButtonVisible(false);
|
|
93
|
+
onSkipButtonHidden?.(currentSegment);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
previousSegmentRef.current = currentSegment;
|
|
97
|
+
setCurrentSegment(newSegment);
|
|
98
|
+
|
|
99
|
+
if (newSegment) {
|
|
100
|
+
// Entering new segment
|
|
101
|
+
onSegmentEntered?.(newSegment);
|
|
102
|
+
|
|
103
|
+
// Show skip button for skippable segments
|
|
104
|
+
if (shouldShowSkipButton(newSegment)) {
|
|
105
|
+
setIsSkipButtonVisible(true);
|
|
106
|
+
onSkipButtonShown?.(newSegment);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}, [videoElement, chapters, currentSegment, onSegmentEntered, onSkipButtonShown, onSkipButtonHidden, getCurrentSegment]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if segment should show skip button
|
|
114
|
+
*/
|
|
115
|
+
const shouldShowSkipButton = useCallback((segment: VideoSegment): boolean => {
|
|
116
|
+
if (!config.userPreferences?.showSkipButtons) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Don't show for content segments by default
|
|
121
|
+
if (segment.type === 'content') {
|
|
122
|
+
return segment.showSkipButton === true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Show for other segment types unless explicitly disabled
|
|
126
|
+
return segment.showSkipButton !== false;
|
|
127
|
+
}, [config]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Set up time update listener
|
|
131
|
+
*/
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!videoElement) return;
|
|
134
|
+
|
|
135
|
+
const handler = () => handleTimeUpdate();
|
|
136
|
+
timeUpdateHandlerRef.current = handler;
|
|
137
|
+
|
|
138
|
+
videoElement.addEventListener('timeupdate', handler);
|
|
139
|
+
|
|
140
|
+
return () => {
|
|
141
|
+
videoElement.removeEventListener('timeupdate', handler);
|
|
142
|
+
};
|
|
143
|
+
}, [videoElement, handleTimeUpdate]);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Load chapters
|
|
147
|
+
*/
|
|
148
|
+
const loadChapters = useCallback(async (newChapters: VideoChapters): Promise<void> => {
|
|
149
|
+
// Validate chapters
|
|
150
|
+
if (!newChapters.videoId || !newChapters.duration || !Array.isArray(newChapters.segments)) {
|
|
151
|
+
throw new Error('Invalid chapters data');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sort segments by start time
|
|
155
|
+
const sortedChapters = {
|
|
156
|
+
...newChapters,
|
|
157
|
+
segments: [...newChapters.segments].sort((a, b) => a.startTime - b.startTime)
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
setChapters(sortedChapters);
|
|
161
|
+
|
|
162
|
+
// Check current segment if video is playing
|
|
163
|
+
if (videoElement) {
|
|
164
|
+
const segment = getCurrentSegment(videoElement.currentTime);
|
|
165
|
+
setCurrentSegment(segment);
|
|
166
|
+
}
|
|
167
|
+
}, [videoElement, getCurrentSegment]);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Skip to specific segment
|
|
171
|
+
*/
|
|
172
|
+
const skipToSegment = useCallback((segmentId: string) => {
|
|
173
|
+
if (!chapters || !videoElement) return;
|
|
174
|
+
|
|
175
|
+
const segment = chapters.segments.find(s => s.id === segmentId);
|
|
176
|
+
if (!segment) return;
|
|
177
|
+
|
|
178
|
+
const fromSegment = currentSegment;
|
|
179
|
+
|
|
180
|
+
// Emit skip event
|
|
181
|
+
if (fromSegment) {
|
|
182
|
+
onSegmentSkipped?.(fromSegment, segment);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Seek to segment start
|
|
186
|
+
videoElement.currentTime = segment.startTime;
|
|
187
|
+
}, [chapters, videoElement, currentSegment, onSegmentSkipped]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Skip current segment
|
|
191
|
+
*/
|
|
192
|
+
const skipCurrentSegment = useCallback(() => {
|
|
193
|
+
if (!currentSegment || !chapters || !videoElement) return;
|
|
194
|
+
|
|
195
|
+
// Find next content segment
|
|
196
|
+
const sortedSegments = [...chapters.segments].sort((a, b) => a.startTime - b.startTime);
|
|
197
|
+
const currentIndex = sortedSegments.findIndex(s => s.id === currentSegment.id);
|
|
198
|
+
|
|
199
|
+
if (currentIndex === -1) return;
|
|
200
|
+
|
|
201
|
+
// Find next content segment
|
|
202
|
+
let nextSegment: VideoSegment | undefined;
|
|
203
|
+
for (let i = currentIndex + 1; i < sortedSegments.length; i++) {
|
|
204
|
+
if (sortedSegments[i].type === 'content') {
|
|
205
|
+
nextSegment = sortedSegments[i];
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const targetTime = nextSegment ? nextSegment.startTime : currentSegment.endTime;
|
|
211
|
+
|
|
212
|
+
// Emit skip event
|
|
213
|
+
onSegmentSkipped?.(currentSegment, nextSegment);
|
|
214
|
+
|
|
215
|
+
// Seek to target time
|
|
216
|
+
videoElement.currentTime = targetTime;
|
|
217
|
+
}, [currentSegment, chapters, videoElement, onSegmentSkipped]);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get segments by type
|
|
221
|
+
*/
|
|
222
|
+
const getSegmentsByType = useCallback((type: SegmentType): VideoSegment[] => {
|
|
223
|
+
if (!chapters) return [];
|
|
224
|
+
return chapters.segments.filter(segment => segment.type === type);
|
|
225
|
+
}, [chapters]);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check if has segment type
|
|
229
|
+
*/
|
|
230
|
+
const hasSegmentType = useCallback((type: SegmentType): boolean => {
|
|
231
|
+
return getSegmentsByType(type).length > 0;
|
|
232
|
+
}, [getSegmentsByType]);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get chapter markers for progress bar
|
|
236
|
+
*/
|
|
237
|
+
const getChapterMarkers = useCallback(() => {
|
|
238
|
+
if (!chapters) return [];
|
|
239
|
+
|
|
240
|
+
const segmentColors: Record<SegmentType, string> = {
|
|
241
|
+
intro: '#ff5722',
|
|
242
|
+
recap: '#ffc107',
|
|
243
|
+
content: '#4caf50',
|
|
244
|
+
credits: '#9c27b0',
|
|
245
|
+
ad: '#f44336'
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return chapters.segments
|
|
249
|
+
.filter(segment => segment.type !== 'content')
|
|
250
|
+
.map(segment => ({
|
|
251
|
+
position: (segment.startTime / chapters.duration) * 100,
|
|
252
|
+
segment,
|
|
253
|
+
color: segmentColors[segment.type]
|
|
254
|
+
}));
|
|
255
|
+
}, [chapters]);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Format time as MM:SS or HH:MM:SS
|
|
259
|
+
*/
|
|
260
|
+
const formatTime = useCallback((seconds: number): string => {
|
|
261
|
+
if (!seconds || isNaN(seconds)) return '00:00';
|
|
262
|
+
|
|
263
|
+
const hours = Math.floor(seconds / 3600);
|
|
264
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
265
|
+
const secs = Math.floor(seconds % 60);
|
|
266
|
+
|
|
267
|
+
if (hours > 0) {
|
|
268
|
+
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
269
|
+
} else {
|
|
270
|
+
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
271
|
+
}
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check if currently in specific segment
|
|
276
|
+
*/
|
|
277
|
+
const isInSegment = useCallback((segmentId: string): boolean => {
|
|
278
|
+
return currentSegment?.id === segmentId;
|
|
279
|
+
}, [currentSegment]);
|
|
280
|
+
|
|
281
|
+
// Load initial chapters
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (initialChapters) {
|
|
284
|
+
loadChapters(initialChapters);
|
|
285
|
+
}
|
|
286
|
+
}, [initialChapters, loadChapters]);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
// State
|
|
290
|
+
currentSegment,
|
|
291
|
+
chapters,
|
|
292
|
+
isSkipButtonVisible,
|
|
293
|
+
|
|
294
|
+
// Actions
|
|
295
|
+
loadChapters,
|
|
296
|
+
skipToSegment,
|
|
297
|
+
skipCurrentSegment,
|
|
298
|
+
|
|
299
|
+
// Queries
|
|
300
|
+
getSegmentsByType,
|
|
301
|
+
hasSegmentType,
|
|
302
|
+
getChapterMarkers,
|
|
303
|
+
|
|
304
|
+
// Utils
|
|
305
|
+
formatTime,
|
|
306
|
+
isInSegment
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -122,4 +122,4 @@ export interface ProgramBlock extends TimeRange {
|
|
|
122
122
|
|
|
123
123
|
export type EPGViewMode = 'grid' | 'list' | 'compact';
|
|
124
124
|
export type EPGSortBy = 'time' | 'channel' | 'category' | 'rating';
|
|
125
|
-
export type EPGFilterType = 'all' | 'favorites' | 'recordings' | 'reminders' | 'category';
|
|
125
|
+
export type EPGFilterType = 'all' | 'favorites' | 'recordings' | 'reminders' | 'category';
|