Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, ReactNode, TouchEvent } from "react";
|
|
import { GradientSpinner } from "./GradientSpinner";
|
|
import { RefreshCw } from "lucide-react";
|
|
|
|
interface PullToRefreshProps {
|
|
children: ReactNode;
|
|
threshold?: number;
|
|
}
|
|
|
|
export function PullToRefresh({
|
|
children,
|
|
threshold = 80,
|
|
}: PullToRefreshProps) {
|
|
const [pullDistance, setPullDistance] = useState(0);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const startY = useRef(0);
|
|
const isPulling = useRef(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleTouchStart = useCallback((e: TouchEvent) => {
|
|
// Only allow pull-to-refresh when scrolled to the top
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
// Check if the main element inside is scrolled to top
|
|
const mainElement = container.querySelector("main");
|
|
if (mainElement && mainElement.scrollTop === 0) {
|
|
startY.current = e.touches[0].clientY;
|
|
isPulling.current = true;
|
|
}
|
|
}, []);
|
|
|
|
const handleTouchMove = useCallback(
|
|
(e: TouchEvent) => {
|
|
if (!isPulling.current || isRefreshing) return;
|
|
|
|
const currentY = e.touches[0].clientY;
|
|
const distance = currentY - startY.current;
|
|
|
|
// Only track downward pulls
|
|
if (distance > 0) {
|
|
// Apply resistance factor for smoother feel
|
|
const resistance = 0.5;
|
|
const adjustedDistance = distance * resistance;
|
|
|
|
setPullDistance(adjustedDistance);
|
|
|
|
// Prevent default scroll behavior when pulling
|
|
if (adjustedDistance > 10) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
},
|
|
[isRefreshing]
|
|
);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
if (!isPulling.current) return;
|
|
|
|
isPulling.current = false;
|
|
|
|
// Check if we've pulled past the threshold
|
|
if (pullDistance >= threshold) {
|
|
setIsRefreshing(true);
|
|
setPullDistance(threshold); // Lock at threshold during refresh
|
|
|
|
// Trigger full page reload after a brief delay for visual feedback
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 300);
|
|
} else {
|
|
// Reset if not past threshold
|
|
setPullDistance(0);
|
|
}
|
|
}, [pullDistance, threshold]);
|
|
|
|
// Calculate visual properties based on pull progress
|
|
const pullProgress = Math.min(pullDistance / threshold, 1);
|
|
const showIndicator = pullDistance > 0;
|
|
const shouldRelease = pullDistance >= threshold;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
className="relative h-full"
|
|
style={{ touchAction: "pan-y" }}
|
|
>
|
|
{/* Pull-to-refresh indicator */}
|
|
{showIndicator && (
|
|
<div
|
|
className="absolute top-0 left-0 right-0 z-50 flex flex-col items-center justify-center pointer-events-none"
|
|
style={{
|
|
transform: `translateY(${Math.min(pullDistance, threshold + 20)}px)`,
|
|
opacity: pullProgress,
|
|
transition: isPulling.current
|
|
? "none"
|
|
: "transform 0.3s ease-out, opacity 0.3s ease-out",
|
|
willChange: "transform",
|
|
}}
|
|
>
|
|
<div className="bg-black/80 backdrop-blur-sm rounded-full p-3 shadow-lg border border-white/10">
|
|
{isRefreshing ? (
|
|
<GradientSpinner size="sm" />
|
|
) : (
|
|
<RefreshCw
|
|
className={`w-5 h-5 text-white transition-transform ${
|
|
shouldRelease ? "rotate-180" : ""
|
|
}`}
|
|
style={{
|
|
transform: `rotate(${pullDistance * 2}deg)`,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className="text-white/80 text-xs mt-2 font-medium">
|
|
{isRefreshing
|
|
? "Refreshing..."
|
|
: shouldRelease
|
|
? "Release to refresh"
|
|
: "Pull to refresh"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|