Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
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
This commit is contained in:
133
frontend/components/ui/PullToRefresh.tsx
Normal file
133
frontend/components/ui/PullToRefresh.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user