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
text/typescript
/**
* 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;
}
}
}