unified-video-framework
Version:
Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more
596 lines (507 loc) • 18.5 kB
text/typescript
/**
* Core chapter management system for video segments and skip functionality
*/
import {
VideoSegment,
VideoChapters,
ChapterConfig,
ChapterEvents,
ChapterMarker,
SegmentType,
DEFAULT_CHAPTER_CONFIG,
SEGMENT_COLORS
} from './types/ChapterTypes';
import { SkipButtonController } from './SkipButtonController';
import { CreditsButtonController } from './CreditsButtonController';
export class ChapterManager {
private chapters: VideoChapters | null = null;
private currentSegment: VideoSegment | null = null;
private previousSegment: VideoSegment | null = null;
private skipButtonController: SkipButtonController;
private creditsButtonController: CreditsButtonController;
private config: ChapterConfig;
private eventListeners: Map<keyof ChapterEvents, Function[]> = new Map();
private isDestroyed = false;
constructor(
private playerContainer: HTMLElement,
private videoElement: HTMLVideoElement,
config: ChapterConfig = DEFAULT_CHAPTER_CONFIG
) {
// Merge config with defaults
this.config = { ...DEFAULT_CHAPTER_CONFIG, ...config };
// Initialize skip button controller
this.skipButtonController = new SkipButtonController(
playerContainer,
this.config,
(segment) => this.skipToNextSegment(segment),
(segment) => this.emit('skipButtonShown', { segment, currentTime: this.videoElement.currentTime }),
(segment, reason) => this.emit('skipButtonHidden', {
segment,
currentTime: this.videoElement.currentTime,
reason: reason as any
})
);
// Initialize credits button controller
this.creditsButtonController = new CreditsButtonController(
playerContainer,
this.config,
{
onWatchCredits: (segment) => this.handleWatchCredits(segment),
onNextEpisode: (segment, url) => this.handleNextEpisode(segment, url),
onAutoRedirect: (segment, url) => this.handleAutoRedirect(segment, url),
onButtonsShown: (segment) => this.emit('skipButtonShown', { segment, currentTime: this.videoElement.currentTime }),
onButtonsHidden: (segment, reason) => this.emit('skipButtonHidden', {
segment,
currentTime: this.videoElement.currentTime,
reason: reason as any
})
}
);
// Set up time update listener
this.setupTimeUpdateListener();
// Load chapters if provided in config
if (this.config.data) {
this.loadChapters(this.config.data);
} else if (this.config.dataUrl) {
this.loadChaptersFromUrl(this.config.dataUrl);
}
}
/**
* Load chapters data
*/
public async loadChapters(chapters: VideoChapters): Promise<void> {
try {
// Validate chapters data
this.validateChapters(chapters);
this.chapters = chapters;
this.sortSegments();
// Emit loaded event
this.emit('chaptersLoaded', {
chapters: this.chapters,
segmentCount: this.chapters.segments.length
});
// Update chapter markers if enabled
if (this.config.showChapterMarkers) {
this.updateChapterMarkers();
}
// Check current segment
this.checkCurrentSegment(this.videoElement.currentTime);
} catch (error) {
this.emit('chaptersLoadError', {
error: error as Error
});
throw error;
}
}
/**
* Load chapters from URL
*/
public async loadChaptersFromUrl(url: string): Promise<void> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load chapters: ${response.statusText}`);
}
const chapters: VideoChapters = await response.json();
await this.loadChapters(chapters);
} catch (error) {
this.emit('chaptersLoadError', {
error: error as Error,
url
});
throw error;
}
}
/**
* Get current segment at given time
*/
public getCurrentSegment(currentTime: number): VideoSegment | null {
if (!this.chapters) return null;
return this.chapters.segments.find(segment =>
currentTime >= segment.startTime && currentTime < segment.endTime
) || null;
}
/**
* Skip to next segment after current one
*/
public skipToNextSegment(currentSegment: VideoSegment): void {
if (!this.chapters) return;
const nextSegment = this.getNextContentSegment(currentSegment);
const targetTime = nextSegment ? nextSegment.startTime : currentSegment.endTime;
// Store current playback state
const wasPlaying = !this.videoElement.paused;
// Emit skip event
this.emit('segmentSkipped', {
fromSegment: currentSegment,
toSegment: nextSegment || undefined,
skipMethod: 'button',
currentTime: this.videoElement.currentTime
});
// Seek to target time
this.videoElement.currentTime = targetTime;
// Resume playback if video was playing before skip (better UX)
const shouldResumePlayback = this.config.userPreferences?.resumePlaybackAfterSkip !== false;
if (shouldResumePlayback && wasPlaying && this.videoElement.paused) {
// Use a small delay to ensure seeking is complete
setTimeout(() => {
if (!this.videoElement.paused) return; // Don't play if already playing
this.videoElement.play().catch(() => {
// Handle autoplay restrictions gracefully
console.warn('[ChapterManager] Could not resume playback after skip - user interaction may be required');
});
}, 50);
}
}
/**
* Skip to specific segment by ID
*/
public skipToSegment(segmentId: string): void {
if (!this.chapters) return;
const segment = this.chapters.segments.find(s => s.id === segmentId);
if (!segment) return;
const fromSegment = this.currentSegment;
// Store current playback state
const wasPlaying = !this.videoElement.paused;
// Emit skip event
if (fromSegment) {
this.emit('segmentSkipped', {
fromSegment,
toSegment: segment,
skipMethod: 'manual',
currentTime: this.videoElement.currentTime
});
}
// Seek to segment start
this.videoElement.currentTime = segment.startTime;
// Resume playback if video was playing before skip (better UX)
const shouldResumePlayback = this.config.userPreferences?.resumePlaybackAfterSkip !== false;
if (shouldResumePlayback && wasPlaying && this.videoElement.paused) {
// Use a small delay to ensure seeking is complete
setTimeout(() => {
if (!this.videoElement.paused) return; // Don't play if already playing
this.videoElement.play().catch(() => {
// Handle autoplay restrictions gracefully
console.warn('[ChapterManager] Could not resume playback after skip - user interaction may be required');
});
}, 50);
}
}
/**
* Get all segments
*/
public getSegments(): VideoSegment[] {
return this.chapters?.segments || [];
}
/**
* Get segment by ID
*/
public getSegment(segmentId: string): VideoSegment | null {
if (!this.chapters) return null;
return this.chapters.segments.find(s => s.id === segmentId) || null;
}
/**
* Get segments by type
*/
public getSegmentsByType(type: SegmentType): VideoSegment[] {
if (!this.chapters) return [];
return this.chapters.segments.filter(s => s.type === type);
}
/**
* Get chapter markers for progress bar
*/
public getChapterMarkers(): ChapterMarker[] {
if (!this.chapters || !this.config.showChapterMarkers) return [];
return this.chapters.segments
.filter(segment => segment.type !== 'content') // Don't show markers for content segments
.map(segment => {
// Use custom color if provided, otherwise fallback to default
const customColor = this.config.customStyles?.progressMarkers?.[segment.type];
const color = customColor || SEGMENT_COLORS[segment.type];
return {
segment,
position: (segment.startTime / this.chapters!.duration) * 100,
color,
label: segment.title || segment.type
};
});
}
/**
* Update configuration
*/
public updateConfig(newConfig: Partial<ChapterConfig>): void {
this.config = { ...this.config, ...newConfig };
// Update skip button position if changed
if (newConfig.skipButtonPosition) {
this.skipButtonController.updatePosition(newConfig.skipButtonPosition);
}
// Update chapter markers if setting changed
if ('showChapterMarkers' in newConfig) {
if (newConfig.showChapterMarkers) {
this.updateChapterMarkers();
} else {
this.removeChapterMarkers();
}
}
}
/**
* Add event listener
*/
public on<K extends keyof ChapterEvents>(event: K, listener: (data: ChapterEvents[K]) => void): void {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, []);
}
this.eventListeners.get(event)!.push(listener);
}
/**
* Remove event listener
*/
public off<K extends keyof ChapterEvents>(event: K, listener: (data: ChapterEvents[K]) => void): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
}
}
/**
* Destroy the chapter manager
*/
public destroy(): void {
this.isDestroyed = true;
this.skipButtonController.destroy();
this.creditsButtonController.destroy();
this.removeChapterMarkers();
this.eventListeners.clear();
this.chapters = null;
this.currentSegment = null;
this.previousSegment = null;
}
/**
* Check if chapters are loaded
*/
public hasChapters(): boolean {
return this.chapters !== null && this.chapters.segments.length > 0;
}
/**
* Get current chapter data
*/
public getChapters(): VideoChapters | null {
return this.chapters;
}
/**
* Set up time update listener
*/
private setupTimeUpdateListener(): void {
const handleTimeUpdate = () => {
if (this.isDestroyed) return;
this.checkCurrentSegment(this.videoElement.currentTime);
};
this.videoElement.addEventListener('timeupdate', handleTimeUpdate);
}
/**
* Check and update current segment
*/
private checkCurrentSegment(currentTime: number): void {
if (!this.chapters) return;
const newSegment = this.getCurrentSegment(currentTime);
// Special handling for credits segment with next episode URL
// Check if we're IN credits and approaching/at endTime
if (this.currentSegment?.type === 'credits' &&
this.currentSegment.nextEpisodeUrl &&
this.creditsButtonController.isUserWatchingCredits()) {
// Check if we've reached or passed the credits endTime
if (currentTime >= this.currentSegment.endTime) {
// Capture URL before any state changes
const redirectUrl = this.currentSegment.nextEpisodeUrl;
const creditsSegment = this.currentSegment;
this.emit('creditsFullyWatched', {
segment: creditsSegment,
nextEpisodeUrl: redirectUrl,
currentTime
});
// Hide buttons
this.creditsButtonController.hideCreditsButtons('segment-end');
// Redirect immediately (no need for setTimeout since we're at the end)
window.location.href = redirectUrl;
return; // Exit early to prevent further processing
}
}
// Check if segment changed
if (newSegment !== this.currentSegment) {
// Handle segment exit
if (this.currentSegment) {
this.emit('segmentExited', {
segment: this.currentSegment,
currentTime,
nextSegment: newSegment || undefined
});
// Hide skip button when exiting skippable segments
if (this.shouldShowSkipButton(this.currentSegment)) {
this.skipButtonController.hideSkipButton('segment-end');
}
// Hide credits buttons when exiting credits segment
// (This handles the case where user skipped or segment changed unexpectedly)
if (this.currentSegment.type === 'credits' && this.currentSegment.nextEpisodeUrl) {
this.creditsButtonController.hideCreditsButtons('segment-end');
// Note: Redirect is handled above when endTime is reached
}
}
// Update current segment
this.previousSegment = this.currentSegment;
this.currentSegment = newSegment;
// Handle segment entry
if (this.currentSegment) {
this.emit('segmentEntered', {
segment: this.currentSegment,
currentTime,
previousSegment: this.previousSegment || undefined
});
// Check if this is a credits segment with next episode URL
if (this.currentSegment.type === 'credits' && this.currentSegment.nextEpisodeUrl) {
// Show credits buttons instead of skip button
this.creditsButtonController.showCreditsButtons(this.currentSegment, currentTime);
} else if (this.shouldShowSkipButton(this.currentSegment)) {
// Show skip button for regular skippable segments
this.skipButtonController.showSkipButton(this.currentSegment, currentTime);
}
}
}
}
/**
* Check if segment should show skip button
*/
private shouldShowSkipButton(segment: VideoSegment): boolean {
// Don't show for content segments by default
if (segment.type === 'content') {
return segment.showSkipButton === true;
}
// Show for other segment types unless explicitly disabled
return segment.showSkipButton !== false;
}
/**
* Handle "Watch Credits" button click
*/
private handleWatchCredits(segment: VideoSegment): void {
this.emit('creditsWatched', {
segment,
currentTime: this.videoElement.currentTime
});
}
/**
* Handle "Next Episode" button click
*/
private handleNextEpisode(segment: VideoSegment, url: string): void {
this.emit('nextEpisodeClicked', {
segment,
nextEpisodeUrl: url,
currentTime: this.videoElement.currentTime
});
}
/**
* Handle auto-redirect when countdown expires
*/
private handleAutoRedirect(segment: VideoSegment, url: string): void {
this.emit('creditsAutoRedirect', {
segment,
nextEpisodeUrl: url,
currentTime: this.videoElement.currentTime
});
}
/**
* Get next content segment after current segment
*/
private getNextContentSegment(currentSegment: VideoSegment): VideoSegment | null {
if (!this.chapters) return null;
const sortedSegments = [...this.chapters.segments].sort((a, b) => a.startTime - b.startTime);
const currentIndex = sortedSegments.findIndex(s => s.id === currentSegment.id);
if (currentIndex === -1) return null;
// Find next content segment
for (let i = currentIndex + 1; i < sortedSegments.length; i++) {
if (sortedSegments[i].type === 'content') {
return sortedSegments[i];
}
}
return null;
}
/**
* Sort segments by start time
*/
private sortSegments(): void {
if (this.chapters) {
this.chapters.segments.sort((a, b) => a.startTime - b.startTime);
}
}
/**
* Validate chapters data
*/
private validateChapters(chapters: VideoChapters): void {
if (!chapters.videoId) {
throw new Error('Chapters must have a videoId');
}
if (!chapters.duration || chapters.duration <= 0) {
throw new Error('Chapters must have a valid duration');
}
if (!Array.isArray(chapters.segments)) {
throw new Error('Chapters must have a segments array');
}
// Validate each segment
chapters.segments.forEach((segment, index) => {
if (!segment.id) {
throw new Error(`Segment at index ${index} must have an id`);
}
if (!segment.type) {
throw new Error(`Segment at index ${index} must have a type`);
}
if (segment.startTime < 0 || segment.endTime <= segment.startTime) {
throw new Error(`Segment at index ${index} has invalid time range`);
}
if (segment.endTime > chapters.duration) {
throw new Error(`Segment at index ${index} extends beyond video duration`);
}
});
}
/**
* Update chapter markers on progress bar
*/
private updateChapterMarkers(): void {
if (!this.chapters || !this.config.showChapterMarkers) return;
const progressBar = this.playerContainer.querySelector('.uvf-progress-bar');
if (!progressBar) return;
// Remove existing markers
this.removeChapterMarkers();
// Add new markers
const markers = this.getChapterMarkers();
markers.forEach(marker => {
const markerElement = document.createElement('div');
markerElement.className = `uvf-chapter-marker uvf-chapter-marker-${marker.segment.type}`;
markerElement.style.left = `${marker.position}%`;
markerElement.style.backgroundColor = marker.color || SEGMENT_COLORS[marker.segment.type];
markerElement.setAttribute('title', marker.label || '');
markerElement.setAttribute('data-segment-id', marker.segment.id);
progressBar.appendChild(markerElement);
});
}
/**
* Remove chapter markers from progress bar
*/
private removeChapterMarkers(): void {
const markers = this.playerContainer.querySelectorAll('.uvf-chapter-marker');
markers.forEach(marker => marker.remove());
}
/**
* Emit event
*/
private emit<K extends keyof ChapterEvents>(event: K, data: ChapterEvents[K]): void {
const listeners = this.eventListeners.get(event);
if (listeners) {
listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
console.error(`Error in chapter event listener for ${event}:`, error);
}
});
}
}
}