feat(chromecast): add new player UI with mini player, hooks, and utilities

- Create ChromecastMiniPlayer component (bottom bar navigation)
- Create chromecast-player modal route with full UI
- Add useChromecastPlayer hook (playback controls & state)
- Add useChromecastSegments hook (intro/credits/segments)
- Add chromecast options (constants & config)
- Add chromecast helpers (time formatting, quality checks)
- Implement swipe-down gesture to dismiss
- Add Netflix-style buffering indicator
- Add progress tracking with trickplay support
- Add next episode countdown
- Ready for segments integration from autoskip branch
This commit is contained in:
Uruk
2026-01-19 22:02:06 +01:00
parent 1176905cb7
commit 45f7923e84
8 changed files with 1440 additions and 4 deletions

147
utils/chromecast/helpers.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* Chromecast utility helper functions
*/
import { CONNECTION_QUALITY, type ConnectionQuality } from "./options";
/**
* Formats milliseconds to HH:MM:SS or MM:SS
*/
export const formatTime = (ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (num: number) => num.toString().padStart(2, "0");
if (hours > 0) {
return `${hours}:${pad(minutes)}:${pad(seconds)}`;
}
return `${minutes}:${pad(seconds)}`;
};
/**
* Calculates ending time based on current time and remaining duration
*/
export const calculateEndingTime = (
remainingMs: number,
use24Hour = true,
): string => {
const endTime = new Date(Date.now() + remainingMs);
const hours = endTime.getHours();
const minutes = endTime.getMinutes();
if (use24Hour) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${period}`;
};
/**
* Determines connection quality based on bitrate and latency
*/
export const getConnectionQuality = (
bitrateMbps: number,
latencyMs?: number,
): ConnectionQuality => {
// Prioritize bitrate, but factor in latency if available
let effectiveBitrate = bitrateMbps;
if (latencyMs !== undefined && latencyMs > 200) {
effectiveBitrate *= 0.7; // Reduce effective quality for high latency
}
if (effectiveBitrate >= CONNECTION_QUALITY.EXCELLENT.min) {
return "EXCELLENT";
}
if (effectiveBitrate >= CONNECTION_QUALITY.GOOD.min) {
return "GOOD";
}
if (effectiveBitrate >= CONNECTION_QUALITY.FAIR.min) {
return "FAIR";
}
return "POOR";
};
/**
* Checks if we should show next episode countdown
*/
export const shouldShowNextEpisodeCountdown = (
remainingMs: number,
hasNextEpisode: boolean,
countdownStartSeconds: number,
): boolean => {
return hasNextEpisode && remainingMs <= countdownStartSeconds * 1000;
};
/**
* Truncates long titles with ellipsis
*/
export const truncateTitle = (title: string, maxLength: number): string => {
if (title.length <= maxLength) return title;
return `${title.substring(0, maxLength - 3)}...`;
};
/**
* Formats episode info (e.g., "S1 E1" or "Episode 1")
*/
export const formatEpisodeInfo = (
seasonNumber?: number | null,
episodeNumber?: number | null,
): string => {
if (
seasonNumber !== undefined &&
seasonNumber !== null &&
episodeNumber !== undefined &&
episodeNumber !== null
) {
return `S${seasonNumber} E${episodeNumber}`;
}
if (episodeNumber !== undefined && episodeNumber !== null) {
return `Episode ${episodeNumber}`;
}
return "";
};
/**
* Gets the appropriate poster URL (season for series, primary for movies)
*/
export const getPosterUrl = (
item: {
Type?: string;
ParentBackdropImageTags?: string[];
SeriesId?: string;
Id?: string;
},
api: { basePath?: string },
): string | null => {
if (!api.basePath) return null;
if (item.Type === "Episode" && item.SeriesId) {
// Use season poster for episodes
return `${api.basePath}/Items/${item.SeriesId}/Images/Primary`;
}
// Use primary image for movies and other types
if (item.Id) {
return `${api.basePath}/Items/${item.Id}/Images/Primary`;
}
return null;
};
/**
* Checks if currently within a segment (intro, credits, etc.)
*/
export const isWithinSegment = (
currentMs: number,
segment: { start: number; end: number } | null,
): boolean => {
if (!segment) return false;
const currentSeconds = currentMs / 1000;
return currentSeconds >= segment.start && currentSeconds <= segment.end;
};

View File

@@ -0,0 +1,70 @@
/**
* Chromecast player configuration and constants
*/
export const CHROMECAST_CONSTANTS = {
// Timing
PROGRESS_REPORT_INTERVAL: 10, // seconds
CONTROLS_TIMEOUT: 5000, // ms
BUFFERING_THRESHOLD: 10, // seconds of buffer before hiding indicator
NEXT_EPISODE_COUNTDOWN_START: 30, // seconds before end
CONNECTION_CHECK_INTERVAL: 5000, // ms
// UI
POSTER_WIDTH: 300,
POSTER_HEIGHT: 450,
MINI_PLAYER_HEIGHT: 80,
SKIP_FORWARD_TIME: 15, // seconds (overridden by settings)
SKIP_BACKWARD_TIME: 15, // seconds (overridden by settings)
// Animation
ANIMATION_DURATION: 300, // ms
BLUR_RADIUS: 10,
} as const;
export const CONNECTION_QUALITY = {
EXCELLENT: { min: 50, label: "Excellent", icon: "signal" },
GOOD: { min: 30, label: "Good", icon: "signal" },
FAIR: { min: 15, label: "Fair", icon: "signal" },
POOR: { min: 0, label: "Poor", icon: "signal" },
} as const;
export type ConnectionQuality = keyof typeof CONNECTION_QUALITY;
export interface ChromecastPlayerState {
isConnected: boolean;
deviceName: string | null;
isPlaying: boolean;
isPaused: boolean;
isStopped: boolean;
isBuffering: boolean;
progress: number; // milliseconds
duration: number; // milliseconds
volume: number; // 0-1
isMuted: boolean;
currentItemId: string | null;
connectionQuality: ConnectionQuality;
}
export interface ChromecastSegmentData {
intro: { start: number; end: number } | null;
credits: { start: number; end: number } | null;
recap: { start: number; end: number } | null;
commercial: { start: number; end: number }[];
preview: { start: number; end: number }[];
}
export const DEFAULT_CHROMECAST_STATE: ChromecastPlayerState = {
isConnected: false,
deviceName: null,
isPlaying: false,
isPaused: false,
isStopped: true,
isBuffering: false,
progress: 0,
duration: 0,
volume: 1,
isMuted: false,
currentItemId: null,
connectionQuality: "EXCELLENT",
};