unified-video-framework
Version:
Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more
372 lines • 14.9 kB
JavaScript
import { DEFAULT_CHAPTER_CONFIG, SEGMENT_COLORS } from './types/ChapterTypes.js';
import { SkipButtonController } from './SkipButtonController.js';
import { CreditsButtonController } from './CreditsButtonController.js';
export class ChapterManager {
constructor(playerContainer, videoElement, config = DEFAULT_CHAPTER_CONFIG) {
this.playerContainer = playerContainer;
this.videoElement = videoElement;
this.chapters = null;
this.currentSegment = null;
this.previousSegment = null;
this.eventListeners = new Map();
this.isDestroyed = false;
this.config = { ...DEFAULT_CHAPTER_CONFIG, ...config };
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
}));
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
})
});
this.setupTimeUpdateListener();
if (this.config.data) {
this.loadChapters(this.config.data);
}
else if (this.config.dataUrl) {
this.loadChaptersFromUrl(this.config.dataUrl);
}
}
async loadChapters(chapters) {
try {
this.validateChapters(chapters);
this.chapters = chapters;
this.sortSegments();
this.emit('chaptersLoaded', {
chapters: this.chapters,
segmentCount: this.chapters.segments.length
});
if (this.config.showChapterMarkers) {
this.updateChapterMarkers();
}
this.checkCurrentSegment(this.videoElement.currentTime);
}
catch (error) {
this.emit('chaptersLoadError', {
error: error
});
throw error;
}
}
async loadChaptersFromUrl(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load chapters: ${response.statusText}`);
}
const chapters = await response.json();
await this.loadChapters(chapters);
}
catch (error) {
this.emit('chaptersLoadError', {
error: error,
url
});
throw error;
}
}
getCurrentSegment(currentTime) {
if (!this.chapters)
return null;
return this.chapters.segments.find(segment => currentTime >= segment.startTime && currentTime < segment.endTime) || null;
}
skipToNextSegment(currentSegment) {
if (!this.chapters)
return;
const nextSegment = this.getNextContentSegment(currentSegment);
const targetTime = nextSegment ? nextSegment.startTime : currentSegment.endTime;
const wasPlaying = !this.videoElement.paused;
this.emit('segmentSkipped', {
fromSegment: currentSegment,
toSegment: nextSegment || undefined,
skipMethod: 'button',
currentTime: this.videoElement.currentTime
});
this.videoElement.currentTime = targetTime;
const shouldResumePlayback = this.config.userPreferences?.resumePlaybackAfterSkip !== false;
if (shouldResumePlayback && wasPlaying && this.videoElement.paused) {
setTimeout(() => {
if (!this.videoElement.paused)
return;
this.videoElement.play().catch(() => {
console.warn('[ChapterManager] Could not resume playback after skip - user interaction may be required');
});
}, 50);
}
}
skipToSegment(segmentId) {
if (!this.chapters)
return;
const segment = this.chapters.segments.find(s => s.id === segmentId);
if (!segment)
return;
const fromSegment = this.currentSegment;
const wasPlaying = !this.videoElement.paused;
if (fromSegment) {
this.emit('segmentSkipped', {
fromSegment,
toSegment: segment,
skipMethod: 'manual',
currentTime: this.videoElement.currentTime
});
}
this.videoElement.currentTime = segment.startTime;
const shouldResumePlayback = this.config.userPreferences?.resumePlaybackAfterSkip !== false;
if (shouldResumePlayback && wasPlaying && this.videoElement.paused) {
setTimeout(() => {
if (!this.videoElement.paused)
return;
this.videoElement.play().catch(() => {
console.warn('[ChapterManager] Could not resume playback after skip - user interaction may be required');
});
}, 50);
}
}
getSegments() {
return this.chapters?.segments || [];
}
getSegment(segmentId) {
if (!this.chapters)
return null;
return this.chapters.segments.find(s => s.id === segmentId) || null;
}
getSegmentsByType(type) {
if (!this.chapters)
return [];
return this.chapters.segments.filter(s => s.type === type);
}
getChapterMarkers() {
if (!this.chapters || !this.config.showChapterMarkers)
return [];
return this.chapters.segments
.filter(segment => segment.type !== 'content')
.map(segment => {
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
};
});
}
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
if (newConfig.skipButtonPosition) {
this.skipButtonController.updatePosition(newConfig.skipButtonPosition);
}
if ('showChapterMarkers' in newConfig) {
if (newConfig.showChapterMarkers) {
this.updateChapterMarkers();
}
else {
this.removeChapterMarkers();
}
}
}
on(event, listener) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, []);
}
this.eventListeners.get(event).push(listener);
}
off(event, listener) {
const listeners = this.eventListeners.get(event);
if (listeners) {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
}
}
destroy() {
this.isDestroyed = true;
this.skipButtonController.destroy();
this.creditsButtonController.destroy();
this.removeChapterMarkers();
this.eventListeners.clear();
this.chapters = null;
this.currentSegment = null;
this.previousSegment = null;
}
hasChapters() {
return this.chapters !== null && this.chapters.segments.length > 0;
}
getChapters() {
return this.chapters;
}
setupTimeUpdateListener() {
const handleTimeUpdate = () => {
if (this.isDestroyed)
return;
this.checkCurrentSegment(this.videoElement.currentTime);
};
this.videoElement.addEventListener('timeupdate', handleTimeUpdate);
}
checkCurrentSegment(currentTime) {
if (!this.chapters)
return;
const newSegment = this.getCurrentSegment(currentTime);
if (this.currentSegment?.type === 'credits' &&
this.currentSegment.nextEpisodeUrl &&
this.creditsButtonController.isUserWatchingCredits()) {
if (currentTime >= this.currentSegment.endTime) {
const redirectUrl = this.currentSegment.nextEpisodeUrl;
const creditsSegment = this.currentSegment;
this.emit('creditsFullyWatched', {
segment: creditsSegment,
nextEpisodeUrl: redirectUrl,
currentTime
});
this.creditsButtonController.hideCreditsButtons('segment-end');
window.location.href = redirectUrl;
return;
}
}
if (newSegment !== this.currentSegment) {
if (this.currentSegment) {
this.emit('segmentExited', {
segment: this.currentSegment,
currentTime,
nextSegment: newSegment || undefined
});
if (this.shouldShowSkipButton(this.currentSegment)) {
this.skipButtonController.hideSkipButton('segment-end');
}
if (this.currentSegment.type === 'credits' && this.currentSegment.nextEpisodeUrl) {
this.creditsButtonController.hideCreditsButtons('segment-end');
}
}
this.previousSegment = this.currentSegment;
this.currentSegment = newSegment;
if (this.currentSegment) {
this.emit('segmentEntered', {
segment: this.currentSegment,
currentTime,
previousSegment: this.previousSegment || undefined
});
if (this.currentSegment.type === 'credits' && this.currentSegment.nextEpisodeUrl) {
this.creditsButtonController.showCreditsButtons(this.currentSegment, currentTime);
}
else if (this.shouldShowSkipButton(this.currentSegment)) {
this.skipButtonController.showSkipButton(this.currentSegment, currentTime);
}
}
}
}
shouldShowSkipButton(segment) {
if (segment.type === 'content') {
return segment.showSkipButton === true;
}
return segment.showSkipButton !== false;
}
handleWatchCredits(segment) {
this.emit('creditsWatched', {
segment,
currentTime: this.videoElement.currentTime
});
}
handleNextEpisode(segment, url) {
this.emit('nextEpisodeClicked', {
segment,
nextEpisodeUrl: url,
currentTime: this.videoElement.currentTime
});
}
handleAutoRedirect(segment, url) {
this.emit('creditsAutoRedirect', {
segment,
nextEpisodeUrl: url,
currentTime: this.videoElement.currentTime
});
}
getNextContentSegment(currentSegment) {
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;
for (let i = currentIndex + 1; i < sortedSegments.length; i++) {
if (sortedSegments[i].type === 'content') {
return sortedSegments[i];
}
}
return null;
}
sortSegments() {
if (this.chapters) {
this.chapters.segments.sort((a, b) => a.startTime - b.startTime);
}
}
validateChapters(chapters) {
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');
}
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`);
}
});
}
updateChapterMarkers() {
if (!this.chapters || !this.config.showChapterMarkers)
return;
const progressBar = this.playerContainer.querySelector('.uvf-progress-bar');
if (!progressBar)
return;
this.removeChapterMarkers();
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);
});
}
removeChapterMarkers() {
const markers = this.playerContainer.querySelectorAll('.uvf-chapter-marker');
markers.forEach(marker => marker.remove());
}
emit(event, data) {
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);
}
});
}
}
}
//# sourceMappingURL=ChapterManager.js.map