UNPKG

unified-video-framework

Version:

Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more

448 lines (377 loc) 15.8 kB
/** * Controller for credits dual-button UI (Watch Credits & Next Episode) */ import { VideoSegment, ChapterConfig } from './types/ChapterTypes'; interface CreditsButtonCallbacks { onWatchCredits: (segment: VideoSegment) => void; onNextEpisode: (segment: VideoSegment, url: string) => void; onAutoRedirect: (segment: VideoSegment, url: string) => void; onButtonsShown: (segment: VideoSegment) => void; onButtonsHidden: (segment: VideoSegment, reason: string) => void; } export class CreditsButtonController { private buttonsContainer: HTMLElement | null = null; private watchCreditsButton: HTMLElement | null = null; private nextEpisodeButton: HTMLElement | null = null; private currentSegment: VideoSegment | null = null; private autoRedirectTimeout: NodeJS.Timeout | null = null; private countdownInterval: NodeJS.Timeout | null = null; private isVisible = false; private userWatchingCredits = false; // Default labels private readonly DEFAULT_WATCH_CREDITS_LABEL = 'Watch Credits'; private readonly DEFAULT_NEXT_EPISODE_LABEL = 'Play Next'; private readonly DEFAULT_AUTO_REDIRECT_DELAY = 10; // seconds constructor( private playerContainer: HTMLElement, private config: ChapterConfig, private callbacks: CreditsButtonCallbacks ) { } /** * Show credits buttons for a segment with next episode URL */ public showCreditsButtons(segment: VideoSegment, currentTime: number): void { // Verify segment has nextEpisodeUrl if (!segment.nextEpisodeUrl) { return; } this.currentSegment = segment; this.userWatchingCredits = false; // Create buttons if they don't exist if (!this.buttonsContainer) { this.createCreditsButtons(); } // Update button content this.updateButtonLabels(segment); // Show the buttons this.showButtons(); // Start auto-redirect countdown const delay = segment.autoSkipDelay || this.DEFAULT_AUTO_REDIRECT_DELAY; this.startAutoRedirectCountdown(segment, delay); // Emit event this.callbacks.onButtonsShown(segment); } /** * Hide credits buttons */ public hideCreditsButtons(reason: 'timeout' | 'segment-end' | 'user-action' | 'manual' = 'manual'): void { if (!this.buttonsContainer || !this.isVisible) { return; } // Emit event before cleanup if (this.currentSegment) { this.callbacks.onButtonsHidden(this.currentSegment, reason); } this.hideButtons(); this.clearTimeouts(); this.isVisible = false; this.currentSegment = null; } /** * Check if user is watching credits (clicked "Watch Credits") */ public isUserWatchingCredits(): boolean { return this.userWatchingCredits; } /** * Get current segment */ public getCurrentSegment(): VideoSegment | null { return this.currentSegment; } /** * Check if buttons are visible */ public isButtonsVisible(): boolean { return this.isVisible; } /** * Destroy the credits button controller */ public destroy(): void { this.clearTimeouts(); if (this.buttonsContainer) { this.buttonsContainer.remove(); this.buttonsContainer = null; } this.watchCreditsButton = null; this.nextEpisodeButton = null; this.currentSegment = null; this.isVisible = false; this.userWatchingCredits = false; } /** * Create the credits buttons DOM elements */ private createCreditsButtons(): void { const segment = this.currentSegment; const style = segment?.creditsButtonStyle; const layout = style?.layout || 'vertical'; const position = style?.position || 'bottom-right'; // Create container this.buttonsContainer = document.createElement('div'); this.buttonsContainer.className = 'uvf-credits-buttons'; this.buttonsContainer.setAttribute('role', 'group'); this.buttonsContainer.setAttribute('aria-label', 'Credits navigation'); // Apply container styles based on layout and position const containerStyles: Partial<CSSStyleDeclaration> = { position: 'absolute', display: 'flex', flexDirection: layout === 'horizontal' ? 'row' : 'column', gap: '10px', zIndex: '1000', opacity: '0', transition: 'opacity 0.3s ease-in-out' }; // Position the container this.applyContainerPosition(containerStyles, position); Object.assign(this.buttonsContainer.style, containerStyles); // Create "Watch Credits" button this.watchCreditsButton = document.createElement('button'); this.watchCreditsButton.className = 'uvf-watch-credits-button'; this.watchCreditsButton.setAttribute('type', 'button'); this.watchCreditsButton.setAttribute('aria-label', 'Watch credits'); // Add click handler for Watch Credits this.watchCreditsButton.addEventListener('click', () => this.handleWatchCreditsClick()); // Create "Next Episode" button this.nextEpisodeButton = document.createElement('button'); this.nextEpisodeButton.className = 'uvf-next-episode-button'; this.nextEpisodeButton.setAttribute('type', 'button'); this.nextEpisodeButton.setAttribute('aria-label', 'Play next episode'); // Add click handler for Next Episode this.nextEpisodeButton.addEventListener('click', () => this.handleNextEpisodeClick()); // Apply button styles with custom colors this.applyButtonStyles(this.watchCreditsButton, 'watch-credits', style); this.applyButtonStyles(this.nextEpisodeButton, 'next-episode', style); // Append buttons to container this.buttonsContainer.appendChild(this.watchCreditsButton); this.buttonsContainer.appendChild(this.nextEpisodeButton); // Append container to player this.playerContainer.appendChild(this.buttonsContainer); } /** * Apply position styles to container */ private applyContainerPosition(styles: Partial<CSSStyleDeclaration>, position: string): void { switch (position) { case 'bottom-right': Object.assign(styles, { bottom: '100px', right: '30px' }); break; case 'bottom-left': Object.assign(styles, { bottom: '100px', left: '30px' }); break; case 'bottom-center': Object.assign(styles, { bottom: '100px', left: '50%', transform: 'translateX(-50%)' }); break; case 'top-right': Object.assign(styles, { top: '30px', right: '30px' }); break; case 'top-left': Object.assign(styles, { top: '30px', left: '30px' }); break; case 'top-center': Object.assign(styles, { top: '30px', left: '50%', transform: 'translateX(-50%)' }); break; } } /** * Apply styles to buttons */ private applyButtonStyles(button: HTMLElement, type: 'watch-credits' | 'next-episode', customStyle?: any): void { const baseStyles: Partial<CSSStyleDeclaration> = { padding: '12px 24px', fontSize: '14px', fontWeight: '600', border: 'none', borderRadius: '6px', cursor: 'pointer', transition: 'all 0.2s ease-in-out', fontFamily: 'inherit', outline: 'none', minWidth: '180px', position: 'relative' }; if (type === 'watch-credits') { Object.assign(baseStyles, { backgroundColor: customStyle?.watchCreditsBgColor || 'rgba(255, 255, 255, 0.15)', color: customStyle?.watchCreditsColor || '#ffffff', border: `2px solid ${customStyle?.watchCreditsBgColor ? 'transparent' : 'rgba(255, 255, 255, 0.3)'}` }); } else { Object.assign(baseStyles, { backgroundColor: customStyle?.nextEpisodeBgColor || '#e50914', color: customStyle?.nextEpisodeColor || '#ffffff', boxShadow: '0 2px 8px rgba(229, 9, 20, 0.4)' }); } Object.assign(button.style, baseStyles); // Add hover effect button.addEventListener('mouseenter', () => { if (type === 'watch-credits') { button.style.backgroundColor = customStyle?.watchCreditsBgColor ? this.adjustBrightness(customStyle.watchCreditsBgColor, 20) : 'rgba(255, 255, 255, 0.25)'; } else { button.style.backgroundColor = customStyle?.nextEpisodeBgColor ? this.adjustBrightness(customStyle.nextEpisodeBgColor, 20) : '#f40612'; button.style.transform = 'scale(1.03)'; } }); button.addEventListener('mouseleave', () => { if (type === 'watch-credits') { button.style.backgroundColor = customStyle?.watchCreditsBgColor || 'rgba(255, 255, 255, 0.15)'; } else { button.style.backgroundColor = customStyle?.nextEpisodeBgColor || '#e50914'; button.style.transform = 'scale(1)'; } }); } /** * Adjust color brightness for hover effects */ private adjustBrightness(color: string, percent: number): string { // Simple brightness adjustment - works for hex and rgb const num = parseInt(color.replace('#', ''), 16); const amt = Math.round(2.55 * percent); const R = (num >> 16) + amt; const G = (num >> 8 & 0x00FF) + amt; const B = (num & 0x0000FF) + amt; return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)) .toString(16).slice(1); } /** * Update button labels based on segment configuration */ private updateButtonLabels(segment: VideoSegment): void { if (!this.watchCreditsButton || !this.nextEpisodeButton) return; const watchLabel = segment.watchCreditsLabel || this.DEFAULT_WATCH_CREDITS_LABEL; const nextLabel = segment.nextEpisodeLabel || this.DEFAULT_NEXT_EPISODE_LABEL; this.watchCreditsButton.textContent = watchLabel; this.nextEpisodeButton.textContent = nextLabel; this.watchCreditsButton.setAttribute('aria-label', watchLabel); this.nextEpisodeButton.setAttribute('aria-label', nextLabel); } /** * Show buttons with animation */ private showButtons(): void { if (!this.buttonsContainer) return; this.buttonsContainer.style.opacity = '1'; this.isVisible = true; } /** * Hide buttons with animation */ private hideButtons(): void { if (!this.buttonsContainer) return; // Remove from DOM instead of just hiding with opacity // This prevents accessibility tooltips from showing this.buttonsContainer.remove(); this.buttonsContainer = null; this.watchCreditsButton = null; this.nextEpisodeButton = null; } /** * Handle "Watch Credits" button click */ private handleWatchCreditsClick(): void { if (!this.currentSegment) return; this.userWatchingCredits = true; this.clearTimeouts(); this.hideCreditsButtons('user-action'); // Emit event this.callbacks.onWatchCredits(this.currentSegment); } /** * Handle "Next Episode" button click */ private handleNextEpisodeClick(): void { if (!this.currentSegment || !this.currentSegment.nextEpisodeUrl) return; this.clearTimeouts(); // Emit event this.callbacks.onNextEpisode(this.currentSegment, this.currentSegment.nextEpisodeUrl); // Redirect to next episode this.redirectToNextEpisode(this.currentSegment.nextEpisodeUrl); } /** * Start auto-redirect countdown */ private startAutoRedirectCountdown(segment: VideoSegment, delay: number): void { if (!this.nextEpisodeButton || !segment.nextEpisodeUrl) return; let remainingTime = delay; const originalLabel = segment.nextEpisodeLabel || this.DEFAULT_NEXT_EPISODE_LABEL; // Update button text with countdown this.nextEpisodeButton.textContent = `${originalLabel} (${remainingTime})`; // Create countdown progress bar const progressBar = this.createProgressBar(); this.nextEpisodeButton.appendChild(progressBar); // Update countdown every second this.countdownInterval = setInterval(() => { remainingTime -= 1; if (this.nextEpisodeButton) { this.nextEpisodeButton.textContent = `${originalLabel} (${remainingTime})`; this.nextEpisodeButton.appendChild(progressBar); // Update progress bar const progress = ((delay - remainingTime) / delay) * 100; progressBar.style.width = `${progress}%`; } if (remainingTime <= 0) { this.clearTimeouts(); // Emit auto-redirect event this.callbacks.onAutoRedirect(segment, segment.nextEpisodeUrl!); // Redirect to next episode this.redirectToNextEpisode(segment.nextEpisodeUrl!); } }, 1000); // Set final timeout as backup this.autoRedirectTimeout = setTimeout(() => { if (segment.nextEpisodeUrl) { this.callbacks.onAutoRedirect(segment, segment.nextEpisodeUrl); this.redirectToNextEpisode(segment.nextEpisodeUrl); } }, delay * 1000); } /** * Create countdown progress bar element */ private createProgressBar(): HTMLElement { const progressBar = document.createElement('div'); progressBar.className = 'uvf-countdown-progress'; Object.assign(progressBar.style, { position: 'absolute', bottom: '0', left: '0', height: '3px', backgroundColor: 'rgba(255, 255, 255, 0.9)', width: '0%', transition: 'width 1s linear', borderRadius: '6px' }); return progressBar; } /** * Redirect to next episode URL */ private redirectToNextEpisode(url: string): void { window.location.href = url; } /** * Clear all timeouts and intervals */ private clearTimeouts(): void { if (this.autoRedirectTimeout) { clearTimeout(this.autoRedirectTimeout); this.autoRedirectTimeout = null; } if (this.countdownInterval) { clearInterval(this.countdownInterval); this.countdownInterval = null; } } }