Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const ACTIVITY_PANEL_KEY = "lidify_activity_panel_open";
|
||||
|
||||
export function useActivityPanel() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"notifications" | "active" | "history">("notifications");
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Load state from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem(ACTIVITY_PANEL_KEY);
|
||||
if (stored === "true") {
|
||||
setIsOpen(true);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist state to localStorage
|
||||
useEffect(() => {
|
||||
if (isInitialized && typeof window !== "undefined") {
|
||||
localStorage.setItem(ACTIVITY_PANEL_KEY, isOpen ? "true" : "false");
|
||||
}
|
||||
}, [isOpen, isInitialized]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
toggle,
|
||||
open,
|
||||
close,
|
||||
isInitialized,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getCachedImageUrl } from "@/utils/imageCache";
|
||||
|
||||
/**
|
||||
* Hook that returns a cached blob URL for an image
|
||||
* Prevents image reloading on re-renders by using client-side caching
|
||||
*/
|
||||
export function useCachedImage(url: string | null): string | null {
|
||||
const [cachedUrl, setCachedUrl] = useState<string | null>(url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setCachedUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
getCachedImageUrl(url)
|
||||
.then((blobUrl) => {
|
||||
if (isMounted) {
|
||||
setCachedUrl(blobUrl);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to get cached image:",
|
||||
url,
|
||||
error.message
|
||||
);
|
||||
if (isMounted) {
|
||||
setCachedUrl(url); // Fallback to original URL
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return cachedUrl;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export interface DownloadJob {
|
||||
id: string;
|
||||
type: 'artist' | 'album';
|
||||
subject: string;
|
||||
targetMbid: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
activeDownloads: DownloadJob[];
|
||||
recentDownloads: DownloadJob[];
|
||||
hasActiveDownloads: boolean;
|
||||
failedDownloads: DownloadJob[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to monitor download job status
|
||||
* Polls for active downloads and keeps track of recent completions/failures
|
||||
* @param pollingInterval - How often to poll in milliseconds (default: 15000)
|
||||
* @param isAuthenticated - Whether the user is authenticated (required to prevent polling when logged out)
|
||||
*/
|
||||
export function useDownloadStatus(pollingInterval: number = 15000, isAuthenticated: boolean = false) {
|
||||
const [status, setStatus] = useState<DownloadStatus>({
|
||||
activeDownloads: [],
|
||||
recentDownloads: [],
|
||||
hasActiveDownloads: false,
|
||||
failedDownloads: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Don't poll if user is not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let pollTimeout: NodeJS.Timeout | null = null;
|
||||
let errorCount = 0;
|
||||
|
||||
const pollDownloads = async () => {
|
||||
try {
|
||||
// Fetch recent download jobs (last 50)
|
||||
const response = await api.getDownloads(50);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Reset error count on successful request
|
||||
errorCount = 0;
|
||||
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
const activeDownloads = response.filter(
|
||||
(job) => job.status === 'pending' || job.status === 'processing'
|
||||
);
|
||||
|
||||
const recentDownloads = response.filter(
|
||||
(job) =>
|
||||
(job.status === 'completed' || job.status === 'failed') &&
|
||||
new Date(job.completedAt || job.createdAt) > fiveMinutesAgo
|
||||
);
|
||||
|
||||
const failedDownloads = response.filter(
|
||||
(job) => job.status === 'failed' && new Date(job.completedAt || job.createdAt) > fiveMinutesAgo
|
||||
);
|
||||
|
||||
setStatus({
|
||||
activeDownloads,
|
||||
recentDownloads,
|
||||
hasActiveDownloads: activeDownloads.length > 0,
|
||||
failedDownloads,
|
||||
});
|
||||
|
||||
// Continue polling if there are active downloads
|
||||
if (activeDownloads.length > 0) {
|
||||
pollTimeout = setTimeout(pollDownloads, pollingInterval);
|
||||
} else {
|
||||
// Check again in longer interval if no active downloads (30 seconds)
|
||||
pollTimeout = setTimeout(pollDownloads, 30000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to poll download status:', error);
|
||||
|
||||
// Increment error count
|
||||
errorCount++;
|
||||
|
||||
// Exponential backoff on errors (max 2 minutes)
|
||||
const backoffDelay = Math.min(pollingInterval * Math.pow(2, errorCount), 120000);
|
||||
|
||||
// Silently continue on rate limit errors - don't spam console
|
||||
if (error.message !== 'Too Many Requests') {
|
||||
console.error('Download polling error:', error);
|
||||
}
|
||||
|
||||
// Retry with backoff
|
||||
if (mounted) {
|
||||
pollTimeout = setTimeout(pollDownloads, backoffDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling
|
||||
pollDownloads();
|
||||
|
||||
// Listen for download status changes (e.g., when user clears history)
|
||||
const handleDownloadStatusChanged = () => {
|
||||
pollDownloads();
|
||||
};
|
||||
window.addEventListener('download-status-changed', handleDownloadStatusChanged);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (pollTimeout) {
|
||||
clearTimeout(pollTimeout);
|
||||
}
|
||||
window.removeEventListener('download-status-changed', handleDownloadStatusChanged);
|
||||
};
|
||||
}, [pollingInterval, isAuthenticated]);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface ColorPalette {
|
||||
vibrant: string;
|
||||
darkVibrant: string;
|
||||
lightVibrant: string;
|
||||
muted: string;
|
||||
darkMuted: string;
|
||||
lightMuted: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dominant colors from an image using Canvas API
|
||||
*/
|
||||
function extractColorsFromImage(imageUrl: string): Promise<ColorPalette> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
// ALWAYS set crossOrigin for images that need color extraction
|
||||
// This is required for Canvas.getImageData() to work
|
||||
// The backend must send Access-Control-Allow-Origin header
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
// Create canvas and get context
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("Could not get canvas context");
|
||||
}
|
||||
|
||||
// Scale down for performance
|
||||
const scaleFactor = 0.1;
|
||||
canvas.width = img.width * scaleFactor;
|
||||
canvas.height = img.height * scaleFactor;
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Get image data
|
||||
const imageData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
const pixels = imageData.data;
|
||||
|
||||
// Count color frequencies, prioritizing saturated colors
|
||||
const colorMap = new Map<
|
||||
string,
|
||||
{ count: number; saturation: number }
|
||||
>();
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const r = pixels[i];
|
||||
const g = pixels[i + 1];
|
||||
const b = pixels[i + 2];
|
||||
const a = pixels[i + 3];
|
||||
|
||||
// Skip transparent pixels
|
||||
if (a < 125) continue;
|
||||
|
||||
// Skip extremely dark and extremely light pixels
|
||||
const brightness = (r + g + b) / 3;
|
||||
if (brightness < 15 || brightness > 240) continue;
|
||||
|
||||
// Calculate saturation to favor colorful pixels
|
||||
const saturation = getSaturation(r, g, b);
|
||||
|
||||
// Reduce color precision for better grouping
|
||||
const reducedR = Math.floor(r / 15) * 15;
|
||||
const reducedG = Math.floor(g / 15) * 15;
|
||||
const reducedB = Math.floor(b / 15) * 15;
|
||||
const key = `${reducedR},${reducedG},${reducedB}`;
|
||||
|
||||
const existing = colorMap.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.saturation = Math.max(
|
||||
existing.saturation,
|
||||
saturation
|
||||
);
|
||||
} else {
|
||||
colorMap.set(key, { count: 1, saturation });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort colors by a weighted score (frequency + saturation)
|
||||
const sortedColors = Array.from(colorMap.entries())
|
||||
.sort((a, b) => {
|
||||
// Weight both frequency and saturation
|
||||
const scoreA = a[1].count * (1 + a[1].saturation * 2);
|
||||
const scoreB = b[1].count * (1 + b[1].saturation * 2);
|
||||
return scoreB - scoreA;
|
||||
})
|
||||
.map(([color]) => {
|
||||
const [r, g, b] = color.split(",").map(Number);
|
||||
return { r, g, b };
|
||||
});
|
||||
|
||||
if (sortedColors.length === 0) {
|
||||
// Fallback colors
|
||||
resolve({
|
||||
vibrant: "#1db954",
|
||||
darkVibrant: "#121212",
|
||||
lightVibrant: "#181818",
|
||||
muted: "#535353",
|
||||
darkMuted: "#121212",
|
||||
lightMuted: "#b3b3b3",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find vibrant color (highest saturation) and boost if too dark
|
||||
const vibrantColor = sortedColors.reduce((prev, curr) => {
|
||||
const prevSat = getSaturation(prev.r, prev.g, prev.b);
|
||||
const currSat = getSaturation(curr.r, curr.g, curr.b);
|
||||
return currSat > prevSat ? curr : prev;
|
||||
});
|
||||
|
||||
// Boost vibrant color if it's too dark - be VERY aggressive
|
||||
const vibrantBrightness =
|
||||
(vibrantColor.r + vibrantColor.g + vibrantColor.b) / 3;
|
||||
let boostFactor: number;
|
||||
|
||||
if (vibrantBrightness < 30) {
|
||||
// Extremely dark - boost by 8-10x
|
||||
boostFactor = 10.0;
|
||||
} else if (vibrantBrightness < 60) {
|
||||
// Very dark - boost by 4-5x
|
||||
boostFactor = 5.0;
|
||||
} else if (vibrantBrightness < 100) {
|
||||
// Dark - boost by 2-3x
|
||||
boostFactor = 3.0;
|
||||
} else if (vibrantBrightness < 140) {
|
||||
// Somewhat dark - boost by 1.5-2x
|
||||
boostFactor = 2.0;
|
||||
} else {
|
||||
// Normal brightness
|
||||
boostFactor = 1.3;
|
||||
}
|
||||
|
||||
let boostedVibrant = {
|
||||
r: Math.min(255, Math.floor(vibrantColor.r * boostFactor)),
|
||||
g: Math.min(255, Math.floor(vibrantColor.g * boostFactor)),
|
||||
b: Math.min(255, Math.floor(vibrantColor.b * boostFactor)),
|
||||
};
|
||||
|
||||
// Ensure minimum brightness - if still too dark, add a base amount
|
||||
const boostedBrightness =
|
||||
(boostedVibrant.r + boostedVibrant.g + boostedVibrant.b) /
|
||||
3;
|
||||
if (boostedBrightness < 80) {
|
||||
const addAmount = 80 - boostedBrightness;
|
||||
boostedVibrant = {
|
||||
r: Math.min(255, boostedVibrant.r + addAmount),
|
||||
g: Math.min(255, boostedVibrant.g + addAmount),
|
||||
b: Math.min(255, boostedVibrant.b + addAmount),
|
||||
};
|
||||
}
|
||||
|
||||
// Find dark vibrant (vibrant but darker than original, not boosted)
|
||||
const darkVibrantColor = {
|
||||
r: Math.floor(vibrantColor.r * 0.6),
|
||||
g: Math.floor(vibrantColor.g * 0.6),
|
||||
b: Math.floor(vibrantColor.b * 0.6),
|
||||
};
|
||||
|
||||
// Find light vibrant (from boosted vibrant)
|
||||
const lightVibrantColor = {
|
||||
r: Math.min(255, Math.floor(boostedVibrant.r * 1.2)),
|
||||
g: Math.min(255, Math.floor(boostedVibrant.g * 1.2)),
|
||||
b: Math.min(255, Math.floor(boostedVibrant.b * 1.2)),
|
||||
};
|
||||
|
||||
// Find muted color (low saturation)
|
||||
const mutedColor = sortedColors.reduce((prev, curr) => {
|
||||
const prevSat = getSaturation(prev.r, prev.g, prev.b);
|
||||
const currSat = getSaturation(curr.r, curr.g, curr.b);
|
||||
return currSat < prevSat ? curr : prev;
|
||||
});
|
||||
|
||||
// Dark muted
|
||||
const darkMutedColor = {
|
||||
r: Math.floor(mutedColor.r * 0.4),
|
||||
g: Math.floor(mutedColor.g * 0.4),
|
||||
b: Math.floor(mutedColor.b * 0.4),
|
||||
};
|
||||
|
||||
// Light muted
|
||||
const lightMutedColor = {
|
||||
r: Math.min(255, Math.floor(mutedColor.r * 1.5)),
|
||||
g: Math.min(255, Math.floor(mutedColor.g * 1.5)),
|
||||
b: Math.min(255, Math.floor(mutedColor.b * 1.5)),
|
||||
};
|
||||
|
||||
resolve({
|
||||
vibrant: rgbToHex(
|
||||
boostedVibrant.r,
|
||||
boostedVibrant.g,
|
||||
boostedVibrant.b
|
||||
),
|
||||
darkVibrant: rgbToHex(
|
||||
darkVibrantColor.r,
|
||||
darkVibrantColor.g,
|
||||
darkVibrantColor.b
|
||||
),
|
||||
lightVibrant: rgbToHex(
|
||||
lightVibrantColor.r,
|
||||
lightVibrantColor.g,
|
||||
lightVibrantColor.b
|
||||
),
|
||||
muted: rgbToHex(mutedColor.r, mutedColor.g, mutedColor.b),
|
||||
darkMuted: rgbToHex(
|
||||
darkMutedColor.r,
|
||||
darkMutedColor.g,
|
||||
darkMutedColor.b
|
||||
),
|
||||
lightMuted: rgbToHex(
|
||||
lightMutedColor.r,
|
||||
lightMutedColor.g,
|
||||
lightMutedColor.b
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate saturation of an RGB color
|
||||
*/
|
||||
function getSaturation(r: number, g: number, b: number): number {
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
return max === 0 ? 0 : delta / max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB to hex color
|
||||
*/
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return (
|
||||
"#" +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = x.toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
export function useImageColor(imageUrl: string | null | undefined) {
|
||||
const [colors, setColors] = useState<ColorPalette | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
setColors(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle placeholder images immediately
|
||||
if (
|
||||
imageUrl.includes("placeholder") ||
|
||||
imageUrl.startsWith("/placeholder")
|
||||
) {
|
||||
setColors({
|
||||
vibrant: "#1db954",
|
||||
darkVibrant: "#121212",
|
||||
lightVibrant: "#181818",
|
||||
muted: "#535353",
|
||||
darkMuted: "#121212",
|
||||
lightMuted: "#b3b3b3",
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `color_cache_${imageUrl}`;
|
||||
try {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
const cachedPalette = JSON.parse(cached) as ColorPalette;
|
||||
setColors(cachedPalette);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore cache read errors
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Extract colors client-side using canvas
|
||||
extractColorsFromImage(imageUrl)
|
||||
.then((palette: ColorPalette) => {
|
||||
setColors(palette);
|
||||
setIsLoading(false);
|
||||
|
||||
// Cache the result in localStorage
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(palette));
|
||||
} catch (error) {
|
||||
// Ignore cache write errors
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[useImageColor] Failed to extract colors:", error.message || error);
|
||||
|
||||
// Use fallback colors on error
|
||||
setColors({
|
||||
vibrant: "#1db954",
|
||||
darkVibrant: "#121212",
|
||||
lightVibrant: "#181818",
|
||||
muted: "#535353",
|
||||
darkMuted: "#121212",
|
||||
lightMuted: "#b3b3b3",
|
||||
});
|
||||
setIsLoading(false);
|
||||
|
||||
// Remove any cached failures so it can retry later
|
||||
try {
|
||||
localStorage.removeItem(cacheKey);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
}, [imageUrl]);
|
||||
|
||||
return { colors, isLoading };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a gradient style object from extracted colors
|
||||
*/
|
||||
export function createGradient(
|
||||
colors: ColorPalette | null,
|
||||
fallbackColor: string = "#1db954"
|
||||
): React.CSSProperties {
|
||||
if (!colors) {
|
||||
return {
|
||||
background: `linear-gradient(180deg, ${fallbackColor}40 0%, rgba(0, 0, 0, 0) 100%)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a gradient from dark vibrant to transparent black
|
||||
return {
|
||||
background: `linear-gradient(180deg, ${colors.darkVibrant} 0%, ${colors.darkMuted} 40%, rgba(0, 0, 0, 0.8) 70%, #000000 100%)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hero gradient (for the top section)
|
||||
*/
|
||||
export function createHeroGradient(
|
||||
colors: ColorPalette | null,
|
||||
fallbackColor: string = "#1db954"
|
||||
): React.CSSProperties {
|
||||
if (!colors) {
|
||||
return {
|
||||
background: `linear-gradient(180deg, ${fallbackColor}60 0%, rgba(18, 18, 18, 0.9) 100%)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a more vibrant gradient for the hero section
|
||||
return {
|
||||
background: `linear-gradient(180deg, ${colors.vibrant}40 0%, ${colors.darkVibrant}80 50%, rgba(18, 18, 18, 0.95) 100%)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate relative luminance for contrast ratio
|
||||
* Based on WCAG 2.0 formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
||||
*/
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate contrast ratio between two colors
|
||||
* Returns a value between 1 and 21 (higher is better contrast)
|
||||
* WCAG AA requires 4.5:1 for normal text, 3:1 for large text
|
||||
*/
|
||||
function getContrastRatio(hex1: string, hex2: string): number {
|
||||
const rgb1 = hexToRgb(hex1);
|
||||
const rgb2 = hexToRgb(hex2);
|
||||
|
||||
if (!rgb1 || !rgb2) return 1;
|
||||
|
||||
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
|
||||
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
|
||||
|
||||
const lighter = Math.max(lum1, lum2);
|
||||
const darker = Math.min(lum1, lum2);
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to RGB
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best icon color (black or white) for a given background color
|
||||
* Returns 'black' or 'white' based on contrast ratio
|
||||
*/
|
||||
export function getIconColor(backgroundColor: string): "black" | "white" {
|
||||
const whiteContrast = getContrastRatio(backgroundColor, "#ffffff");
|
||||
const blackContrast = getContrastRatio(backgroundColor, "#000000");
|
||||
|
||||
// Return the color with better contrast
|
||||
// Use white if contrast is close (within 1.5), as it looks better on colorful backgrounds
|
||||
return whiteContrast > blackContrast - 1.5 ? "white" : "black";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get play button styles based on vibrant color
|
||||
* Returns background color and icon color with proper contrast
|
||||
*/
|
||||
export function getPlayButtonStyles(colors: ColorPalette | null): {
|
||||
backgroundColor: string;
|
||||
iconColor: "black" | "white";
|
||||
} {
|
||||
if (!colors) {
|
||||
return {
|
||||
backgroundColor: "#facc15", // Yellow fallback
|
||||
iconColor: "black",
|
||||
};
|
||||
}
|
||||
|
||||
// Use the vibrant color for the button
|
||||
const bgColor = colors.vibrant;
|
||||
const iconColor = getIconColor(bgColor);
|
||||
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
iconColor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export type JobType = "scan" | "discover";
|
||||
|
||||
export interface JobStatus {
|
||||
status: "waiting" | "active" | "completed" | "failed" | "delayed";
|
||||
progress: number;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useJobStatus(
|
||||
jobId: string | null,
|
||||
jobType: JobType,
|
||||
options?: {
|
||||
pollInterval?: number;
|
||||
onComplete?: (result: any) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
) {
|
||||
const [jobStatus, setJobStatus] = useState<JobStatus | null>(null);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const pollInterval = options?.pollInterval || 5000; // Default: poll every 5 seconds (avoid rate limiting)
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
if (!jobId) return;
|
||||
|
||||
try {
|
||||
let statusData;
|
||||
if (jobType === "scan") {
|
||||
statusData = await api.getScanStatus(jobId);
|
||||
} else if (jobType === "discover") {
|
||||
statusData = await api.getDiscoverGenerationStatus(jobId);
|
||||
}
|
||||
|
||||
if (!statusData) return;
|
||||
|
||||
setJobStatus(statusData as JobStatus);
|
||||
|
||||
// Stop polling if job is complete or failed
|
||||
if (statusData.status === "completed") {
|
||||
setIsPolling(false);
|
||||
if (options?.onComplete && statusData.result) {
|
||||
options.onComplete(statusData.result);
|
||||
}
|
||||
} else if (statusData.status === "failed") {
|
||||
setIsPolling(false);
|
||||
if (options?.onError) {
|
||||
const errorMsg =
|
||||
statusData.result?.error ||
|
||||
"Job failed with unknown error";
|
||||
options.onError(errorMsg);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error checking job status:", error);
|
||||
setIsPolling(false);
|
||||
if (options?.onError) {
|
||||
options.onError(error.message || "Failed to check job status");
|
||||
}
|
||||
}
|
||||
}, [jobId, jobType, options]);
|
||||
|
||||
// Start polling when jobId is set
|
||||
useEffect(() => {
|
||||
if (jobId) {
|
||||
setIsPolling(true);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
// Poll for status updates
|
||||
useEffect(() => {
|
||||
if (!isPolling || !jobId) return;
|
||||
|
||||
// Check immediately
|
||||
checkStatus();
|
||||
|
||||
// Then poll at interval
|
||||
const interval = setInterval(checkStatus, pollInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isPolling, jobId, checkStatus, pollInterval]);
|
||||
|
||||
return {
|
||||
jobStatus,
|
||||
isPolling,
|
||||
checkStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAudio } from '@/lib/audio-context';
|
||||
import { useIsTV } from '@/lib/tv-utils';
|
||||
|
||||
/**
|
||||
* Global keyboard shortcuts for media playback
|
||||
*
|
||||
* Shortcuts:
|
||||
* - Space: Play/Pause
|
||||
* - Arrow Right: Seek forward 10s
|
||||
* - Arrow Left: Seek backward 10s
|
||||
* - Arrow Up: Volume up 10%
|
||||
* - Arrow Down: Volume down 10%
|
||||
* - M: Toggle mute
|
||||
* - N: Next track
|
||||
* - P: Previous track
|
||||
* - S: Toggle shuffle
|
||||
*/
|
||||
export function useKeyboardShortcuts() {
|
||||
const isTV = useIsTV();
|
||||
const {
|
||||
isPlaying,
|
||||
resume,
|
||||
pause,
|
||||
next,
|
||||
previous,
|
||||
seek,
|
||||
currentTime,
|
||||
setVolume,
|
||||
volume,
|
||||
toggleMute,
|
||||
toggleShuffle,
|
||||
playbackType,
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
} = useAudio();
|
||||
|
||||
useEffect(() => {
|
||||
// Disable keyboard shortcuts on TV - use remote's media keys instead
|
||||
if (isTV) return;
|
||||
|
||||
// Don't add shortcuts if nothing is loaded
|
||||
if (!playbackType) return;
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Ignore if user is typing in an input/textarea
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent default for media keys to avoid conflicts
|
||||
if ([' ', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case ' ': // Space - Play/Pause
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'arrowright': // Right arrow - Seek forward 10s
|
||||
if (playbackType === 'track' || playbackType === 'audiobook' || playbackType === 'podcast') {
|
||||
const duration = currentTrack?.duration || currentAudiobook?.duration || currentPodcast?.duration || 0;
|
||||
seek(Math.min(currentTime + 10, duration));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'arrowleft': // Left arrow - Seek backward 10s
|
||||
if (playbackType === 'track' || playbackType === 'audiobook' || playbackType === 'podcast') {
|
||||
seek(Math.max(currentTime - 10, 0));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'arrowup': // Up arrow - Volume up 10%
|
||||
setVolume(Math.min(volume + 0.1, 1));
|
||||
break;
|
||||
|
||||
case 'arrowdown': // Down arrow - Volume down 10%
|
||||
setVolume(Math.max(volume - 0.1, 0));
|
||||
break;
|
||||
|
||||
case 'm': // M - Toggle mute
|
||||
toggleMute();
|
||||
break;
|
||||
|
||||
case 'n': // N - Next track
|
||||
if (playbackType === 'track') {
|
||||
next();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'p': // P - Previous track
|
||||
if (playbackType === 'track' && !e.shiftKey) { // Avoid conflict with Shift+P
|
||||
previous();
|
||||
}
|
||||
break;
|
||||
|
||||
case 's': // S - Toggle shuffle
|
||||
if (playbackType === 'track') {
|
||||
toggleShuffle();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, [
|
||||
isTV,
|
||||
isPlaying,
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
seek,
|
||||
currentTime,
|
||||
setVolume,
|
||||
volume,
|
||||
toggleMute,
|
||||
toggleShuffle,
|
||||
playbackType,
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client side
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const media = window.matchMedia(query);
|
||||
|
||||
// Set initial value
|
||||
setMatches(media.matches);
|
||||
|
||||
// Create listener
|
||||
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
|
||||
// Add listener
|
||||
if (media.addEventListener) {
|
||||
media.addEventListener("change", listener);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
media.addListener(listener);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (media.removeEventListener) {
|
||||
media.removeEventListener("change", listener);
|
||||
} else {
|
||||
media.removeListener(listener);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Common breakpoints
|
||||
export const useIsMobile = () => useMediaQuery("(max-width: 768px)");
|
||||
export const useIsTablet = () => useMediaQuery("(min-width: 769px) and (max-width: 1024px)");
|
||||
export const useIsDesktop = () => useMediaQuery("(min-width: 1025px)");
|
||||
export const useIsTV = () => useMediaQuery("(min-width: 1920px)");
|
||||
export const useIsLargeTV = () => useMediaQuery("(min-width: 2560px)");
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Media Session API integration for OS-level media controls
|
||||
*
|
||||
* Features:
|
||||
* - Lock screen controls (iOS/Android)
|
||||
* - Media keys (play/pause, next, previous)
|
||||
* - Now playing notification
|
||||
* - Album art display
|
||||
* - Seek controls (on supported platforms)
|
||||
*/
|
||||
export function useMediaSession() {
|
||||
const {
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
isPlaying,
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
seek,
|
||||
currentTime,
|
||||
} = useAudio();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Media Session API is supported
|
||||
if (!("mediaSession" in navigator)) {
|
||||
console.warn("[MediaSession] Media Session API not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metadata when track/audiobook/podcast changes
|
||||
if (playbackType === "track" && currentTrack) {
|
||||
const coverUrl = currentTrack.album?.coverArt
|
||||
? api.getCoverArtUrl(currentTrack.album.coverArt, 512)
|
||||
: undefined;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist?.name || "Unknown Artist",
|
||||
album: currentTrack.album?.title || "Unknown Album",
|
||||
artwork: coverUrl
|
||||
? [
|
||||
{ src: coverUrl, sizes: "96x96", type: "image/jpeg" },
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "128x128",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "192x192",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "256x256",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "384x384",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "512x512",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
} else if (playbackType === "audiobook" && currentAudiobook) {
|
||||
const coverUrl = currentAudiobook.coverUrl
|
||||
? api.getCoverArtUrl(currentAudiobook.coverUrl, 512)
|
||||
: undefined;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentAudiobook.title,
|
||||
artist: currentAudiobook.author,
|
||||
album: currentAudiobook.narrator
|
||||
? `Narrated by ${currentAudiobook.narrator}`
|
||||
: "Audiobook",
|
||||
artwork: coverUrl
|
||||
? [
|
||||
{ src: coverUrl, sizes: "96x96", type: "image/jpeg" },
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "128x128",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "192x192",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "256x256",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "384x384",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "512x512",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
} else if (playbackType === "podcast" && currentPodcast) {
|
||||
const coverUrl = currentPodcast.coverUrl
|
||||
? api.getCoverArtUrl(currentPodcast.coverUrl, 512)
|
||||
: undefined;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentPodcast.title,
|
||||
artist: currentPodcast.podcastTitle,
|
||||
album: "Podcast",
|
||||
artwork: coverUrl
|
||||
? [
|
||||
{ src: coverUrl, sizes: "96x96", type: "image/jpeg" },
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "128x128",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "192x192",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "256x256",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "384x384",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
src: coverUrl,
|
||||
sizes: "512x512",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
// Clear metadata when nothing is playing
|
||||
navigator.mediaSession.metadata = null;
|
||||
}
|
||||
|
||||
// Update playback state
|
||||
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
|
||||
}, [
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
isPlaying,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
|
||||
// Register action handlers
|
||||
navigator.mediaSession.setActionHandler("play", () => {
|
||||
resume();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler("pause", () => {
|
||||
pause();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler("previoustrack", () => {
|
||||
if (playbackType === "track") {
|
||||
previous();
|
||||
} else {
|
||||
// For audiobooks/podcasts, seek backward 30s
|
||||
seek(Math.max(currentTime - 30, 0));
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
||||
if (playbackType === "track") {
|
||||
next();
|
||||
} else {
|
||||
// For audiobooks/podcasts, seek forward 30s
|
||||
const duration =
|
||||
currentAudiobook?.duration || currentPodcast?.duration || 0;
|
||||
seek(Math.min(currentTime + 30, duration));
|
||||
}
|
||||
});
|
||||
|
||||
// Seek controls (may not be supported on all platforms)
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(
|
||||
"seekbackward",
|
||||
(details) => {
|
||||
const skipTime = details.seekOffset || 10;
|
||||
seek(Math.max(currentTime - skipTime, 0));
|
||||
}
|
||||
);
|
||||
|
||||
navigator.mediaSession.setActionHandler(
|
||||
"seekforward",
|
||||
(details) => {
|
||||
const skipTime = details.seekOffset || 10;
|
||||
const duration =
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration ||
|
||||
0;
|
||||
seek(Math.min(currentTime + skipTime, duration));
|
||||
}
|
||||
);
|
||||
|
||||
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
||||
if (details.seekTime !== undefined) {
|
||||
seek(details.seekTime);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Seek actions not supported on this platform
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if ("mediaSession" in navigator) {
|
||||
navigator.mediaSession.setActionHandler("play", null);
|
||||
navigator.mediaSession.setActionHandler("pause", null);
|
||||
navigator.mediaSession.setActionHandler("previoustrack", null);
|
||||
navigator.mediaSession.setActionHandler("nexttrack", null);
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(
|
||||
"seekbackward",
|
||||
null
|
||||
);
|
||||
navigator.mediaSession.setActionHandler(
|
||||
"seekforward",
|
||||
null
|
||||
);
|
||||
navigator.mediaSession.setActionHandler("seekto", null);
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [
|
||||
pause,
|
||||
resume,
|
||||
next,
|
||||
previous,
|
||||
seek,
|
||||
currentTime,
|
||||
playbackType,
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
]);
|
||||
|
||||
// Update position state for scrubbing on lock screen
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
if (!("setPositionState" in navigator.mediaSession)) return;
|
||||
|
||||
const duration =
|
||||
currentTrack?.duration ||
|
||||
currentAudiobook?.duration ||
|
||||
currentPodcast?.duration;
|
||||
|
||||
if (duration && currentTime !== undefined) {
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration,
|
||||
playbackRate: 1,
|
||||
position: Math.min(currentTime, duration),
|
||||
});
|
||||
} catch (error) {
|
||||
// Some browsers may not support position state
|
||||
console.warn(
|
||||
"[MediaSession] Failed to set position state:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [currentTime, currentTrack, currentAudiobook, currentPodcast]);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
read: boolean;
|
||||
cleared: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
id: string;
|
||||
subject: string;
|
||||
type: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing notifications using React Query as single source of truth.
|
||||
* All components using this hook share the same cache and update together.
|
||||
*/
|
||||
export function useNotifications() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Single source of truth - React Query cache
|
||||
const {
|
||||
data: notifications = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<Notification[]>({
|
||||
queryKey: ["notifications"],
|
||||
queryFn: () => api.get<Notification[]>("/notifications"),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// Derive unread count from data (computed, not stored)
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
// Mark as read mutation with optimistic update
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/notifications/${id}/read`),
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["notifications"] });
|
||||
const previous = queryClient.getQueryData<Notification[]>(["notifications"]);
|
||||
|
||||
queryClient.setQueryData<Notification[]>(["notifications"], (old) =>
|
||||
old?.map((n) => (n.id === id ? { ...n, read: true } : n)) || []
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(["notifications"], context.previous);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Mark all as read mutation with optimistic update
|
||||
const markAllAsReadMutation = useMutation({
|
||||
mutationFn: () => api.post("/notifications/read-all"),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ["notifications"] });
|
||||
const previous = queryClient.getQueryData<Notification[]>(["notifications"]);
|
||||
|
||||
queryClient.setQueryData<Notification[]>(["notifications"], (old) =>
|
||||
old?.map((n) => ({ ...n, read: true })) || []
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(["notifications"], context.previous);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clear notification mutation with optimistic update
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/notifications/${id}/clear`),
|
||||
onMutate: async (id: string) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["notifications"] });
|
||||
const previous = queryClient.getQueryData<Notification[]>(["notifications"]);
|
||||
|
||||
queryClient.setQueryData<Notification[]>(["notifications"], (old) =>
|
||||
old?.filter((n) => n.id !== id) || []
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(["notifications"], context.previous);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clear all mutation with optimistic update
|
||||
const clearAllMutation = useMutation({
|
||||
mutationFn: () => api.post("/notifications/clear-all"),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ["notifications"] });
|
||||
const previous = queryClient.getQueryData<Notification[]>(["notifications"]);
|
||||
|
||||
queryClient.setQueryData<Notification[]>(["notifications"], []);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(["notifications"], context.previous);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
error: error instanceof Error ? error.message : null,
|
||||
refetch,
|
||||
markAsRead: (id: string) => markAsReadMutation.mutate(id),
|
||||
markAllAsRead: () => markAllAsReadMutation.mutate(),
|
||||
clearNotification: (id: string) => clearMutation.mutate(id),
|
||||
clearAll: () => clearAllMutation.mutate(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for download history - unchanged from original
|
||||
*/
|
||||
export function useDownloadHistory() {
|
||||
const fetchHistory = useCallback(async () => {
|
||||
return api.get<DownloadHistoryItem[]>("/notifications/downloads/history");
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: history = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<DownloadHistoryItem[]>({
|
||||
queryKey: ["download-history"],
|
||||
queryFn: fetchHistory,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const clearDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.post(`/notifications/downloads/${id}/clear`);
|
||||
queryClient.setQueryData<DownloadHistoryItem[]>(
|
||||
["download-history"],
|
||||
(old) => old?.filter((d) => d.id !== id) || []
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to clear download:", err);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
try {
|
||||
await api.post("/notifications/downloads/clear-all");
|
||||
queryClient.setQueryData<DownloadHistoryItem[]>(["download-history"], []);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to clear all:", err);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const retryDownload = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.post(`/notifications/downloads/${id}/retry`);
|
||||
queryClient.setQueryData<DownloadHistoryItem[]>(
|
||||
["download-history"],
|
||||
(old) => old?.filter((d) => d.id !== id) || []
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to retry download:", err);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
history,
|
||||
isLoading,
|
||||
error: error instanceof Error ? error.message : null,
|
||||
refetch,
|
||||
clearDownload,
|
||||
clearAll,
|
||||
retryDownload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for active downloads - unchanged from original
|
||||
*/
|
||||
export function useActiveDownloads() {
|
||||
const fetchDownloads = useCallback(async () => {
|
||||
return api.get<DownloadHistoryItem[]>("/notifications/downloads/active");
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: downloads = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<DownloadHistoryItem[]>({
|
||||
queryKey: ["active-downloads"],
|
||||
queryFn: fetchDownloads,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
return {
|
||||
downloads,
|
||||
isLoading,
|
||||
error: error instanceof Error ? error.message : null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAudio } from "@/lib/audio-context";
|
||||
|
||||
/**
|
||||
* Hook that automatically switches player mode based on the current page
|
||||
* - Full player: On media pages (album, audiobook, podcast)
|
||||
* - Mini player: On other pages
|
||||
* - Overlay: Manual user control (doesn't auto-switch)
|
||||
*/
|
||||
export function usePlayerMode() {
|
||||
const pathname = usePathname();
|
||||
const { currentTrack, currentAudiobook, currentPodcast, playerMode, setPlayerMode } = useAudio();
|
||||
|
||||
useEffect(() => {
|
||||
// Don't auto-switch if in overlay mode (user manually opened it)
|
||||
if (playerMode === "overlay") return;
|
||||
|
||||
// Don't auto-switch if no media is playing
|
||||
if (!currentTrack && !currentAudiobook && !currentPodcast) return;
|
||||
|
||||
// Determine if we're on the EXACT page where the current media is playing
|
||||
const isOnCurrentMediaPage =
|
||||
(currentTrack && pathname === `/album/${currentTrack.album?.id}`) ||
|
||||
(currentAudiobook && pathname === `/audiobooks/${currentAudiobook.id}`) ||
|
||||
(currentPodcast && pathname.includes(`/podcasts/${currentPodcast.id}`));
|
||||
|
||||
// Auto-expand to full when on the current media page
|
||||
// But don't auto-minimize - let users keep it expanded if they want
|
||||
if (isOnCurrentMediaPage && playerMode === "mini") {
|
||||
setPlayerMode("full");
|
||||
}
|
||||
}, [
|
||||
pathname,
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playerMode,
|
||||
setPlayerMode,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
/**
|
||||
* React Query Hooks for API Caching
|
||||
*
|
||||
* This file provides custom React Query hooks for the most frequently called APIs
|
||||
* in the music streaming app. These hooks implement smart caching strategies to:
|
||||
* - Reduce unnecessary API calls
|
||||
* - Improve perceived performance
|
||||
* - Provide automatic background refetching
|
||||
* - Handle loading and error states consistently
|
||||
*
|
||||
* Stale time configuration rationale:
|
||||
* - Artist data: 10 minutes (rarely changes)
|
||||
* - Album data: 10 minutes (rarely changes)
|
||||
* - Library/Home data: 2 minutes (may change as user adds music)
|
||||
* - Search results: 5 minutes (relatively static for same query)
|
||||
* - Playlists: 1 minute (user may be actively modifying)
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ============================================================================
|
||||
// QUERY KEY FACTORIES
|
||||
// ============================================================================
|
||||
// These functions generate consistent query keys for caching
|
||||
// See: https://tanstack.com/query/latest/docs/react/guides/query-keys
|
||||
|
||||
export const queryKeys = {
|
||||
// Artist queries
|
||||
artist: (id: string) => ["artist", id] as const,
|
||||
artistLibrary: (id: string) => ["artist", "library", id] as const,
|
||||
artistDiscovery: (id: string) => ["artist", "discovery", id] as const,
|
||||
|
||||
// Album queries
|
||||
album: (id: string) => ["album", id] as const,
|
||||
albumLibrary: (id: string) => ["album", "library", id] as const,
|
||||
albumDiscovery: (id: string) => ["album", "discovery", id] as const,
|
||||
albums: (filters?: Record<string, any>) => ["albums", filters] as const,
|
||||
|
||||
// Library queries
|
||||
library: () => ["library"] as const,
|
||||
recentlyListened: (limit?: number) => ["library", "recently-listened", limit] as const,
|
||||
recentlyAdded: (limit?: number) => ["library", "recently-added", limit] as const,
|
||||
|
||||
// Recommendations
|
||||
recommendations: (limit?: number) => ["recommendations", limit] as const,
|
||||
similarArtists: (seedArtistId: string, limit?: number) => ["recommendations", "artists", seedArtistId, limit] as const,
|
||||
similarAlbums: (seedAlbumId: string, limit?: number) => ["recommendations", "albums", seedAlbumId, limit] as const,
|
||||
|
||||
// Search
|
||||
search: (query: string, type?: string, limit?: number) => ["search", query, type, limit] as const,
|
||||
discoverSearch: (query: string, type?: string, limit?: number) => ["search", "discover", query, type, limit] as const,
|
||||
|
||||
// Playlists
|
||||
playlists: () => ["playlists"] as const,
|
||||
playlist: (id: string) => ["playlist", id] as const,
|
||||
|
||||
// Mixes
|
||||
mixes: () => ["mixes"] as const,
|
||||
mix: (id: string) => ["mix", id] as const,
|
||||
|
||||
// Popular artists
|
||||
popularArtists: (limit?: number) => ["popular-artists", limit] as const,
|
||||
|
||||
// Audiobooks
|
||||
audiobooks: () => ["audiobooks"] as const,
|
||||
audiobook: (id: string) => ["audiobook", id] as const,
|
||||
|
||||
// Podcasts
|
||||
podcasts: () => ["podcasts"] as const,
|
||||
podcast: (id: string) => ["podcast", id] as const,
|
||||
topPodcasts: (limit?: number, genreId?: number) => ["podcasts", "top", limit, genreId] as const,
|
||||
|
||||
// Browse (Deezer playlists/radios)
|
||||
browseAll: () => ["browse", "all"] as const,
|
||||
browseFeatured: (limit?: number) => ["browse", "featured", limit] as const,
|
||||
browseRadios: (limit?: number) => ["browse", "radios", limit] as const,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ARTIST QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch artist data with automatic library/discovery fallback
|
||||
*
|
||||
* Tries library first, falls back to discovery if not found.
|
||||
* Cache time: 10 minutes (artist data rarely changes)
|
||||
*
|
||||
* @param id - Artist ID or MusicBrainz ID
|
||||
* @returns Query result with artist data
|
||||
*
|
||||
* @example
|
||||
* const { data: artist, isLoading, error } = useArtistQuery("artist-123");
|
||||
*/
|
||||
export function useArtistQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.artist(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Artist ID is required");
|
||||
|
||||
// Try library first
|
||||
try {
|
||||
return await api.getArtist(id);
|
||||
} catch (error) {
|
||||
// Fallback to discovery
|
||||
return await api.getArtistDiscovery(id);
|
||||
}
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch artist data from library only
|
||||
*
|
||||
* @param id - Artist ID
|
||||
* @returns Query result with artist data from library
|
||||
*/
|
||||
export function useArtistLibraryQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.artistLibrary(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Artist ID is required");
|
||||
return await api.getArtist(id);
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch artist data from discovery only
|
||||
*
|
||||
* @param id - Artist name or MusicBrainz ID
|
||||
* @returns Query result with artist data from Last.fm
|
||||
*/
|
||||
export function useArtistDiscoveryQuery(nameOrMbid: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.artistDiscovery(nameOrMbid || ""),
|
||||
queryFn: async () => {
|
||||
if (!nameOrMbid) throw new Error("Artist name or MBID is required");
|
||||
return await api.getArtistDiscovery(nameOrMbid);
|
||||
},
|
||||
enabled: !!nameOrMbid,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ALBUM QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch album data with automatic library/discovery fallback
|
||||
*
|
||||
* Cache time: 10 minutes (album data rarely changes)
|
||||
*
|
||||
* @param id - Album ID or Release Group MBID
|
||||
* @returns Query result with album data
|
||||
*
|
||||
* @example
|
||||
* const { data: album, isLoading, error } = useAlbumQuery("album-123");
|
||||
*/
|
||||
export function useAlbumQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.album(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Album ID is required");
|
||||
|
||||
// Try library first
|
||||
try {
|
||||
return await api.getAlbum(id);
|
||||
} catch (error) {
|
||||
// Fallback to discovery
|
||||
return await api.getAlbumDiscovery(id);
|
||||
}
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch album data from library only
|
||||
*
|
||||
* @param id - Album ID
|
||||
* @returns Query result with album data from library
|
||||
*/
|
||||
export function useAlbumLibraryQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.albumLibrary(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Album ID is required");
|
||||
return await api.getAlbum(id);
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch album data from discovery only
|
||||
*
|
||||
* @param rgMbid - Release Group MusicBrainz ID
|
||||
* @returns Query result with album data from Last.fm
|
||||
*/
|
||||
export function useAlbumDiscoveryQuery(rgMbid: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.albumDiscovery(rgMbid || ""),
|
||||
queryFn: async () => {
|
||||
if (!rgMbid) throw new Error("Album MBID is required");
|
||||
return await api.getAlbumDiscovery(rgMbid);
|
||||
},
|
||||
enabled: !!rgMbid,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch albums list with optional filters
|
||||
*
|
||||
* @param params - Filter parameters (artistId, limit, offset)
|
||||
* @returns Query result with albums array
|
||||
*
|
||||
* @example
|
||||
* const { data } = useAlbumsQuery({ artistId: "123", limit: 20 });
|
||||
*/
|
||||
export function useAlbumsQuery(params?: {
|
||||
artistId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.albums(params),
|
||||
queryFn: () => api.getAlbums(params),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIBRARY QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch recently listened items (Continue Listening)
|
||||
*
|
||||
* Cache time: 2 minutes (may change frequently)
|
||||
*
|
||||
* @param limit - Number of items to fetch (default: 10)
|
||||
* @returns Query result with recently listened items
|
||||
*
|
||||
* @example
|
||||
* const { data } = useRecentlyListenedQuery(10);
|
||||
*/
|
||||
export function useRecentlyListenedQuery(limit: number = 10) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.recentlyListened(limit),
|
||||
queryFn: () => api.getRecentlyListened(limit),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch recently added artists
|
||||
*
|
||||
* Cache time: 2 minutes (may change as user adds music)
|
||||
*
|
||||
* @param limit - Number of items to fetch (default: 10)
|
||||
* @returns Query result with recently added artists
|
||||
*
|
||||
* @example
|
||||
* const { data } = useRecentlyAddedQuery(10);
|
||||
*/
|
||||
export function useRecentlyAddedQuery(limit: number = 10) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.recentlyAdded(limit),
|
||||
queryFn: () => api.getRecentlyAdded(limit),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RECOMMENDATION QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch personalized recommendations
|
||||
*
|
||||
* Cache time: 5 minutes
|
||||
*
|
||||
* @param limit - Number of recommendations (default: 10)
|
||||
* @returns Query result with recommended artists
|
||||
*
|
||||
* @example
|
||||
* const { data } = useRecommendationsQuery(10);
|
||||
*/
|
||||
export function useRecommendationsQuery(limit: number = 10) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.recommendations(limit),
|
||||
queryFn: () => api.getRecommendationsForYou(limit),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch similar artists based on a seed artist
|
||||
*
|
||||
* @param seedArtistId - Artist ID to find similar artists for
|
||||
* @param limit - Number of recommendations (default: 20)
|
||||
* @returns Query result with similar artists
|
||||
*
|
||||
* @example
|
||||
* const { data } = useSimilarArtistsQuery("artist-123", 20);
|
||||
*/
|
||||
export function useSimilarArtistsQuery(seedArtistId: string | undefined, limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.similarArtists(seedArtistId || "", limit),
|
||||
queryFn: async () => {
|
||||
if (!seedArtistId) throw new Error("Seed artist ID is required");
|
||||
return await api.getSimilarArtists(seedArtistId, limit);
|
||||
},
|
||||
enabled: !!seedArtistId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch similar albums based on a seed album
|
||||
*
|
||||
* @param seedAlbumId - Album ID to find similar albums for
|
||||
* @param limit - Number of recommendations (default: 20)
|
||||
* @returns Query result with similar albums
|
||||
*/
|
||||
export function useSimilarAlbumsQuery(seedAlbumId: string | undefined, limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.similarAlbums(seedAlbumId || "", limit),
|
||||
queryFn: async () => {
|
||||
if (!seedAlbumId) throw new Error("Seed album ID is required");
|
||||
return await api.getSimilarAlbums(seedAlbumId, limit);
|
||||
},
|
||||
enabled: !!seedAlbumId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to search library with debouncing
|
||||
*
|
||||
* Cache time: 5 minutes (search results are relatively static)
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param type - Type filter (all, artists, albums, tracks, audiobooks, podcasts)
|
||||
* @param limit - Number of results per type (default: 20)
|
||||
* @returns Query result with search results
|
||||
*
|
||||
* @example
|
||||
* const { data } = useSearchQuery("radiohead", "all", 20);
|
||||
*/
|
||||
export function useSearchQuery(
|
||||
query: string,
|
||||
type: "all" | "artists" | "albums" | "tracks" | "audiobooks" | "podcasts" = "all",
|
||||
limit: number = 20
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.search(query, type, limit),
|
||||
queryFn: () => api.search(query, type, limit),
|
||||
enabled: query.length >= 2, // Only search if query is at least 2 characters
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to search discovery/Last.fm with debouncing
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param type - Type filter (music, podcasts, all)
|
||||
* @param limit - Number of results (default: 20)
|
||||
* @returns Query result with discovery search results
|
||||
*
|
||||
* @example
|
||||
* const { data } = useDiscoverSearchQuery("radiohead", "music", 20);
|
||||
*/
|
||||
export function useDiscoverSearchQuery(
|
||||
query: string,
|
||||
type: "music" | "podcasts" | "all" = "music",
|
||||
limit: number = 20
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.discoverSearch(query, type, limit),
|
||||
queryFn: () => api.discoverSearch(query, type, limit),
|
||||
enabled: query.length >= 2,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAYLIST QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch all playlists
|
||||
*
|
||||
* Cache time: 1 minute (playlists may be actively modified)
|
||||
*
|
||||
* @returns Query result with playlists array
|
||||
*
|
||||
* @example
|
||||
* const { data: playlists } = usePlaylistsQuery();
|
||||
*/
|
||||
export function usePlaylistsQuery() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.playlists(),
|
||||
queryFn: () => api.getPlaylists(),
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single playlist
|
||||
*
|
||||
* @param id - Playlist ID
|
||||
* @returns Query result with playlist data
|
||||
*
|
||||
* @example
|
||||
* const { data: playlist } = usePlaylistQuery("playlist-123");
|
||||
*/
|
||||
export function usePlaylistQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.playlist(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Playlist ID is required");
|
||||
return await api.getPlaylist(id);
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MIX QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch all mixes (Made For You)
|
||||
*
|
||||
* Cache time: 5 minutes
|
||||
*
|
||||
* @returns Query result with mixes array
|
||||
*
|
||||
* @example
|
||||
* const { data: mixes } = useMixesQuery();
|
||||
*/
|
||||
export function useMixesQuery() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.mixes(),
|
||||
queryFn: () => api.getMixes(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single mix
|
||||
*
|
||||
* @param id - Mix ID
|
||||
* @returns Query result with mix data
|
||||
*/
|
||||
export function useMixQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.mix(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Mix ID is required");
|
||||
return await api.getMix(id);
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POPULAR ARTISTS QUERY
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch popular artists from Last.fm
|
||||
*
|
||||
* Cache time: 10 minutes (popular charts don't change frequently)
|
||||
*
|
||||
* @param limit - Number of artists to fetch (default: 20)
|
||||
* @returns Query result with popular artists
|
||||
*
|
||||
* @example
|
||||
* const { data } = usePopularArtistsQuery(20);
|
||||
*/
|
||||
export function usePopularArtistsQuery(limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.popularArtists(limit),
|
||||
queryFn: () => api.getPopularArtists(limit),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIOBOOK QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch all audiobooks
|
||||
*
|
||||
* @returns Query result with audiobooks array
|
||||
*/
|
||||
export function useAudiobooksQuery() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.audiobooks(),
|
||||
queryFn: () => api.getAudiobooks(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single audiobook
|
||||
*
|
||||
* @param id - Audiobook ID
|
||||
* @returns Query result with audiobook data
|
||||
*/
|
||||
export function useAudiobookQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.audiobook(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Audiobook ID is required");
|
||||
return await api.getAudiobook(id);
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PODCAST QUERIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch all subscribed podcasts
|
||||
*
|
||||
* @returns Query result with podcasts array
|
||||
*/
|
||||
export function usePodcastsQuery() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.podcasts(),
|
||||
queryFn: () => api.getPodcasts(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single podcast
|
||||
*
|
||||
* Returns null if podcast is not found (404), allowing the page to handle preview mode.
|
||||
*
|
||||
* @param id - Podcast ID
|
||||
* @returns Query result with podcast data
|
||||
*/
|
||||
export function usePodcastQuery(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.podcast(id || ""),
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error("Podcast ID is required");
|
||||
|
||||
try {
|
||||
return await api.getPodcast(id);
|
||||
} catch (error: any) {
|
||||
// If podcast not found (404), return null to allow preview mode
|
||||
if (error?.status === 404 || error?.message?.includes('not found') || error?.message?.includes('not subscribed')) {
|
||||
return null;
|
||||
}
|
||||
// For other errors, throw to trigger error state
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: !!id,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: false, // Don't retry 404 errors
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch top podcasts
|
||||
*
|
||||
* @param limit - Number of podcasts (default: 20)
|
||||
* @param genreId - Optional genre ID filter
|
||||
* @returns Query result with top podcasts
|
||||
*/
|
||||
export function useTopPodcastsQuery(limit: number = 20, genreId?: number) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.topPodcasts(limit, genreId),
|
||||
queryFn: () => api.getTopPodcasts(limit, genreId),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MUTATION HOOKS
|
||||
// ============================================================================
|
||||
// These hooks handle data modifications and automatically invalidate related queries
|
||||
|
||||
/**
|
||||
* Hook to refresh mixes with cache invalidation
|
||||
*
|
||||
* @returns Mutation object with mutate function
|
||||
*
|
||||
* @example
|
||||
* const { mutate: refreshMixes, isPending } = useRefreshMixesMutation();
|
||||
* refreshMixes();
|
||||
*/
|
||||
export function useRefreshMixesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.refreshMixes(),
|
||||
onSuccess: () => {
|
||||
// Invalidate mixes query to refetch
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mixes() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to add track to playlist with cache invalidation
|
||||
*
|
||||
* @returns Mutation object with mutate function
|
||||
*
|
||||
* @example
|
||||
* const { mutate: addToPlaylist } = useAddToPlaylistMutation();
|
||||
* addToPlaylist({ playlistId: "123", trackId: "456" });
|
||||
*/
|
||||
export function useAddToPlaylistMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ playlistId, trackId }: { playlistId: string; trackId: string }) =>
|
||||
api.addTrackToPlaylist(playlistId, trackId),
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate the specific playlist query
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlist(variables.playlistId)
|
||||
});
|
||||
// Also invalidate the playlists list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists()
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a new playlist with cache invalidation
|
||||
*
|
||||
* @returns Mutation object with mutate function
|
||||
*
|
||||
* @example
|
||||
* const { mutate: createPlaylist } = useCreatePlaylistMutation();
|
||||
* createPlaylist({ name: "My Playlist", isPublic: false });
|
||||
*/
|
||||
export function useCreatePlaylistMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, isPublic }: { name: string; isPublic?: boolean }) =>
|
||||
api.createPlaylist(name, isPublic),
|
||||
onSuccess: () => {
|
||||
// Invalidate playlists list to show new playlist
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists()
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete a playlist with cache invalidation
|
||||
*
|
||||
* @returns Mutation object with mutate function
|
||||
*
|
||||
* @example
|
||||
* const { mutate: deletePlaylist } = useDeletePlaylistMutation();
|
||||
* deletePlaylist("playlist-123");
|
||||
*/
|
||||
export function useDeletePlaylistMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (playlistId: string) => api.deletePlaylist(playlistId),
|
||||
onSuccess: () => {
|
||||
// Invalidate playlists list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.playlists()
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BROWSE QUERIES (Deezer Playlists/Radios)
|
||||
// ============================================================================
|
||||
|
||||
interface PlaylistPreview {
|
||||
id: string;
|
||||
source: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
creator: string;
|
||||
imageUrl: string | null;
|
||||
trackCount: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
interface GenreWithRadios {
|
||||
id: number;
|
||||
name: string;
|
||||
radios: PlaylistPreview[];
|
||||
}
|
||||
|
||||
interface BrowseAllResponse {
|
||||
playlists: PlaylistPreview[];
|
||||
radios: PlaylistPreview[];
|
||||
genres: Genre[];
|
||||
radiosByGenre: GenreWithRadios[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all browse content (playlists, radios, genres) from Deezer
|
||||
*
|
||||
* @returns Query result with all browse content
|
||||
*/
|
||||
export function useBrowseAllQuery() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.browseAll(),
|
||||
queryFn: async (): Promise<BrowseAllResponse> => {
|
||||
return api.get<BrowseAllResponse>("/browse/all");
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes - playlists don't change often
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch featured playlists from Deezer
|
||||
*
|
||||
* @param limit - Maximum number of playlists to fetch
|
||||
* @returns Query result with featured playlists
|
||||
*/
|
||||
export function useFeaturedPlaylistsQuery(limit: number = 50) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.browseFeatured(limit),
|
||||
queryFn: async (): Promise<PlaylistPreview[]> => {
|
||||
const response = await api.get<{ playlists: PlaylistPreview[] }>(
|
||||
`/browse/playlists/featured?limit=${limit}`
|
||||
);
|
||||
return response.playlists;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch radio stations from Deezer
|
||||
*
|
||||
* @param limit - Maximum number of radios to fetch
|
||||
* @returns Query result with radio stations
|
||||
*/
|
||||
export function useRadiosQuery(limit: number = 50) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.browseRadios(limit),
|
||||
queryFn: async (): Promise<PlaylistPreview[]> => {
|
||||
const response = await api.get<{ radios: PlaylistPreview[] }>(
|
||||
`/browse/radios?limit=${limit}`
|
||||
);
|
||||
return response.radios;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useIsTV, DPAD_KEYS } from "@/lib/tv-utils";
|
||||
|
||||
interface UseTVNavigationOptions {
|
||||
onBack?: () => void;
|
||||
onSelect?: (element: HTMLElement) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseTVNavigationResult {
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
focusedSectionIndex: number;
|
||||
focusedCardIndex: number;
|
||||
isContentFocused: boolean;
|
||||
focusFirstCard: () => void;
|
||||
handleKeyDown: (e: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export function useTVNavigation(options: UseTVNavigationOptions = {}): UseTVNavigationResult {
|
||||
const { onBack, onSelect, enabled = true } = options;
|
||||
const isTV = useIsTV();
|
||||
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const [focusedSectionIndex, setFocusedSectionIndex] = useState(0);
|
||||
const [focusedCardIndex, setFocusedCardIndex] = useState(0);
|
||||
const [isContentFocused, setIsContentFocused] = useState(false);
|
||||
|
||||
// Focus memory: remember last focused card index per section
|
||||
const focusMemory = useRef<Map<number, number>>(new Map());
|
||||
|
||||
// Get all sections with data-tv-section attribute
|
||||
const getSections = useCallback(() => {
|
||||
if (!containerRef.current) return [];
|
||||
return Array.from(
|
||||
containerRef.current.querySelectorAll<HTMLElement>('[data-tv-section]')
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Get all cards in a section with data-tv-card attribute
|
||||
const getCardsInSection = useCallback((section: HTMLElement) => {
|
||||
return Array.from(
|
||||
section.querySelectorAll<HTMLElement>('[data-tv-card]')
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Scroll element into view smoothly
|
||||
const scrollIntoView = useCallback((element: HTMLElement) => {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Focus a specific card
|
||||
const focusCard = useCallback((card: HTMLElement | null) => {
|
||||
if (card) {
|
||||
card.focus();
|
||||
scrollIntoView(card);
|
||||
}
|
||||
}, [scrollIntoView]);
|
||||
|
||||
// Focus first card in content area
|
||||
const focusFirstCard = useCallback(() => {
|
||||
const sections = getSections();
|
||||
if (sections.length === 0) {
|
||||
// Fallback: find any focusable element
|
||||
const firstFocusable = containerRef.current?.querySelector<HTMLElement>(
|
||||
'a[href], button, [tabindex="0"]'
|
||||
);
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
setIsContentFocused(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const firstSection = sections[0];
|
||||
const cards = getCardsInSection(firstSection);
|
||||
|
||||
if (cards.length > 0) {
|
||||
focusCard(cards[0]);
|
||||
setFocusedSectionIndex(0);
|
||||
setFocusedCardIndex(0);
|
||||
setIsContentFocused(true);
|
||||
}
|
||||
}, [getSections, getCardsInSection, focusCard]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!enabled || !isTV) return;
|
||||
|
||||
// If not content focused, ignore - let TVLayout handle it
|
||||
if (!isContentFocused) return;
|
||||
|
||||
const sections = getSections();
|
||||
if (sections.length === 0) {
|
||||
// No sections found, try fallback navigation
|
||||
const focusables = containerRef.current?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), [tabindex="0"]'
|
||||
);
|
||||
if (!focusables || focusables.length === 0) return;
|
||||
|
||||
const currentIdx = Array.from(focusables).findIndex(el => el === document.activeElement);
|
||||
if (currentIdx === -1) return;
|
||||
|
||||
if (e.key === DPAD_KEYS.RIGHT || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const next = focusables[Math.min(currentIdx + 1, focusables.length - 1)];
|
||||
next?.focus();
|
||||
} else if (e.key === DPAD_KEYS.LEFT || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prev = focusables[Math.max(currentIdx - 1, 0)];
|
||||
prev?.focus();
|
||||
} else if (e.key === DPAD_KEYS.UP || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
onBack?.();
|
||||
setIsContentFocused(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSection = sections[focusedSectionIndex];
|
||||
if (!currentSection) return;
|
||||
|
||||
const cards = getCardsInSection(currentSection);
|
||||
|
||||
switch (e.key) {
|
||||
case DPAD_KEYS.RIGHT:
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault();
|
||||
const nextIndex = Math.min(focusedCardIndex + 1, cards.length - 1);
|
||||
if (nextIndex !== focusedCardIndex) {
|
||||
focusCard(cards[nextIndex]);
|
||||
setFocusedCardIndex(nextIndex);
|
||||
focusMemory.current.set(focusedSectionIndex, nextIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case DPAD_KEYS.LEFT:
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(focusedCardIndex - 1, 0);
|
||||
if (prevIndex !== focusedCardIndex) {
|
||||
focusCard(cards[prevIndex]);
|
||||
setFocusedCardIndex(prevIndex);
|
||||
focusMemory.current.set(focusedSectionIndex, prevIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case DPAD_KEYS.DOWN:
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
if (focusedSectionIndex < sections.length - 1) {
|
||||
// Save current position
|
||||
focusMemory.current.set(focusedSectionIndex, focusedCardIndex);
|
||||
|
||||
const nextSectionIndex = focusedSectionIndex + 1;
|
||||
const nextSection = sections[nextSectionIndex];
|
||||
const nextCards = getCardsInSection(nextSection);
|
||||
|
||||
if (nextCards.length > 0) {
|
||||
// Try to restore saved position, or use current column, or clamp
|
||||
const savedIndex = focusMemory.current.get(nextSectionIndex);
|
||||
const targetIndex = savedIndex !== undefined
|
||||
? Math.min(savedIndex, nextCards.length - 1)
|
||||
: Math.min(focusedCardIndex, nextCards.length - 1);
|
||||
|
||||
focusCard(nextCards[targetIndex]);
|
||||
setFocusedSectionIndex(nextSectionIndex);
|
||||
setFocusedCardIndex(targetIndex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case DPAD_KEYS.UP:
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
if (focusedSectionIndex > 0) {
|
||||
// Save current position
|
||||
focusMemory.current.set(focusedSectionIndex, focusedCardIndex);
|
||||
|
||||
const prevSectionIndex = focusedSectionIndex - 1;
|
||||
const prevSection = sections[prevSectionIndex];
|
||||
const prevCards = getCardsInSection(prevSection);
|
||||
|
||||
if (prevCards.length > 0) {
|
||||
// Try to restore saved position
|
||||
const savedIndex = focusMemory.current.get(prevSectionIndex);
|
||||
const targetIndex = savedIndex !== undefined
|
||||
? Math.min(savedIndex, prevCards.length - 1)
|
||||
: Math.min(focusedCardIndex, prevCards.length - 1);
|
||||
|
||||
focusCard(prevCards[targetIndex]);
|
||||
setFocusedSectionIndex(prevSectionIndex);
|
||||
setFocusedCardIndex(targetIndex);
|
||||
}
|
||||
} else {
|
||||
// At top section, trigger onBack to return to nav
|
||||
onBack?.();
|
||||
setIsContentFocused(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case DPAD_KEYS.CENTER:
|
||||
case 'Enter': {
|
||||
const focusedElement = document.activeElement as HTMLElement;
|
||||
if (focusedElement) {
|
||||
// If it's a link, let the default behavior happen
|
||||
if (focusedElement.tagName === 'A') {
|
||||
return; // Allow default navigation
|
||||
}
|
||||
// Otherwise trigger onSelect
|
||||
onSelect?.(focusedElement);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case DPAD_KEYS.BACK:
|
||||
case 'Escape': {
|
||||
e.preventDefault();
|
||||
onBack?.();
|
||||
setIsContentFocused(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enabled, isTV, isContentFocused, focusedSectionIndex, focusedCardIndex,
|
||||
getSections, getCardsInSection, focusCard, onBack, onSelect
|
||||
]);
|
||||
|
||||
// Track when content receives focus from outside
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !isTV) return;
|
||||
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.hasAttribute('data-tv-card')) {
|
||||
setIsContentFocused(true);
|
||||
|
||||
// Find which section and card index
|
||||
const sections = getSections();
|
||||
for (let sIdx = 0; sIdx < sections.length; sIdx++) {
|
||||
const cards = getCardsInSection(sections[sIdx]);
|
||||
const cIdx = cards.indexOf(target);
|
||||
if (cIdx !== -1) {
|
||||
setFocusedSectionIndex(sIdx);
|
||||
setFocusedCardIndex(cIdx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
// Check if focus is leaving the container entirely
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!container.contains(relatedTarget)) {
|
||||
setIsContentFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('focusin', handleFocusIn);
|
||||
container.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('focusin', handleFocusIn);
|
||||
container.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, [isTV, getSections, getCardsInSection]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
focusedSectionIndex,
|
||||
focusedCardIndex,
|
||||
isContentFocused,
|
||||
focusFirstCard,
|
||||
handleKeyDown
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user