improved mobile responsiveness + logo
@@ -82,7 +82,7 @@ define(['./workbox-f70c5944'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.qqj3c2rpjk8"
|
"revision": "0.ef94id05f1c"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 758 B After Width: | Height: | Size: 978 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 94 KiB |
@@ -8,19 +8,71 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const publicDir = join(__dirname, '../public');
|
const publicDir = join(__dirname, '../public');
|
||||||
const pngPath = join(publicDir, 'spotizerr.png');
|
const svgPath = join(publicDir, 'spotizerr.svg');
|
||||||
|
|
||||||
async function generateIcons() {
|
async function generateIcons() {
|
||||||
try {
|
try {
|
||||||
// Check if the PNG file exists
|
// Check if the SVG file exists
|
||||||
if (!existsSync(pngPath)) {
|
if (!existsSync(svgPath)) {
|
||||||
throw new Error(`PNG file not found at ${pngPath}. Please ensure spotizerr.png exists in the public directory.`);
|
throw new Error(`SVG file not found at ${svgPath}. Please ensure spotizerr.svg exists in the public directory.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎨 Generating PWA icons from PNG...');
|
console.log('🎨 Generating PWA icons from SVG...');
|
||||||
|
|
||||||
// Since the source is already 1667x1667 (square), we don't need to worry about aspect ratio
|
// First, convert SVG to PNG and process it
|
||||||
const sourceSize = 1667;
|
console.log('📐 Processing SVG: converting to PNG, scaling by 0.67, and centering in black box...');
|
||||||
|
|
||||||
|
// Create a base canvas size for processing
|
||||||
|
const baseCanvasSize = 1000;
|
||||||
|
const scaleFactor = 3;
|
||||||
|
|
||||||
|
// Convert SVG to PNG and get its dimensions
|
||||||
|
const svgToPng = await sharp(svgPath)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const svgMetadata = await sharp(svgToPng).metadata();
|
||||||
|
const svgWidth = svgMetadata.width;
|
||||||
|
const svgHeight = svgMetadata.height;
|
||||||
|
|
||||||
|
// Calculate scaled dimensions
|
||||||
|
const scaledWidth = Math.round(svgWidth * scaleFactor);
|
||||||
|
const scaledHeight = Math.round(svgHeight * scaleFactor);
|
||||||
|
|
||||||
|
// Calculate centering offsets
|
||||||
|
const offsetX = Math.round((baseCanvasSize - scaledWidth) / 2);
|
||||||
|
const offsetY = Math.round((baseCanvasSize - scaledHeight) / 2);
|
||||||
|
|
||||||
|
// Create the processed base image: scale SVG and center in black box
|
||||||
|
const processedImage = await sharp({
|
||||||
|
create: {
|
||||||
|
width: baseCanvasSize,
|
||||||
|
height: baseCanvasSize,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.composite([{
|
||||||
|
input: await sharp(svgToPng)
|
||||||
|
.resize(scaledWidth, scaledHeight)
|
||||||
|
.png()
|
||||||
|
.toBuffer(),
|
||||||
|
top: offsetY,
|
||||||
|
left: offsetX
|
||||||
|
}])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
console.log(`✅ Processed SVG: ${svgWidth}x${svgHeight} → scaled to ${scaledWidth}x${scaledHeight} → centered in ${baseCanvasSize}x${baseCanvasSize} black box`);
|
||||||
|
|
||||||
|
// Save the processed base image for reference
|
||||||
|
await sharp(processedImage)
|
||||||
|
.png()
|
||||||
|
.toFile(join(publicDir, 'spotizerr.png'));
|
||||||
|
|
||||||
|
console.log(`✅ Saved processed base image as spotizerr.png (${baseCanvasSize}x${baseCanvasSize})`);
|
||||||
|
|
||||||
|
const sourceSize = baseCanvasSize;
|
||||||
|
|
||||||
// Define icon configurations
|
// Define icon configurations
|
||||||
const iconConfigs = [
|
const iconConfigs = [
|
||||||
@@ -51,8 +103,8 @@ async function generateIcons() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Load the source PNG
|
// Use the processed image as source
|
||||||
const sourceImage = sharp(pngPath);
|
const sourceImage = sharp(processedImage);
|
||||||
|
|
||||||
for (const config of iconConfigs) {
|
for (const config of iconConfigs) {
|
||||||
const { size, name, padding } = config;
|
const { size, name, padding } = config;
|
||||||
@@ -62,13 +114,13 @@ async function generateIcons() {
|
|||||||
const paddedSize = Math.round(size * (1 - padding * 2));
|
const paddedSize = Math.round(size * (1 - padding * 2));
|
||||||
const offset = Math.round((size - paddedSize) / 2);
|
const offset = Math.round((size - paddedSize) / 2);
|
||||||
|
|
||||||
// Create a black background and composite the resized logo on top
|
// Create a pure black background and composite the resized logo on top
|
||||||
await sharp({
|
await sharp({
|
||||||
create: {
|
create: {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
channels: 4,
|
channels: 4,
|
||||||
background: { r: 15, g: 23, b: 42, alpha: 1 } // #0f172a in RGB
|
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.composite([{
|
.composite([{
|
||||||
@@ -100,7 +152,7 @@ async function generateIcons() {
|
|||||||
width: maskableSize,
|
width: maskableSize,
|
||||||
height: maskableSize,
|
height: maskableSize,
|
||||||
channels: 4,
|
channels: 4,
|
||||||
background: { r: 15, g: 23, b: 42, alpha: 1 } // Solid background for maskable
|
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background for maskable
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.composite([{
|
.composite([{
|
||||||
@@ -125,7 +177,7 @@ async function generateIcons() {
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
channels: 4,
|
channels: 4,
|
||||||
background: { r: 15, g: 23, b: 42, alpha: 1 }
|
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.composite([{
|
.composite([{
|
||||||
@@ -154,7 +206,7 @@ async function generateIcons() {
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
channels: 4,
|
channels: 4,
|
||||||
background: { r: 15, g: 23, b: 42, alpha: 1 }
|
background: { r: 0, g: 0, b: 0, alpha: 1 } // Pure black background
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.composite([{
|
.composite([{
|
||||||
@@ -186,8 +238,8 @@ async function generateIcons() {
|
|||||||
});
|
});
|
||||||
console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48)');
|
console.log(' • favicon.ico (multi-size: 16x16, 32x32, 48x48)');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('💡 The icons are generated with appropriate padding and the dark theme background.');
|
console.log('💡 Icons generated from SVG source, scaled by 0.67, and centered on pure black backgrounds.');
|
||||||
console.log('💡 The source PNG already has the perfect background, so no additional styling needed.');
|
console.log('💡 The SVG logo is automatically processed and optimized for all icon formats.');
|
||||||
console.log('💡 favicon.ico contains multiple sizes for optimal browser compatibility.');
|
console.log('💡 favicon.ico contains multiple sizes for optimal browser compatibility.');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useContext } from "react";
|
import { useContext, useState, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
FaTimes,
|
FaTimes,
|
||||||
FaSync,
|
FaSync,
|
||||||
@@ -127,26 +127,27 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
const progressText = getProgressText();
|
const progressText = getProgressText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] ${statusInfo.bgColor} ${statusInfo.borderColor}`}>
|
<div className={`p-4 md:p-4 rounded-xl border-2 shadow-lg mb-3 transition-all duration-300 hover:shadow-xl md:hover:scale-[1.02] ${statusInfo.bgColor} ${statusInfo.borderColor}`}>
|
||||||
<div className="flex items-center justify-between">
|
{/* Mobile-first layout: stack status and actions on mobile, inline on desktop */}
|
||||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-0">
|
||||||
<div className={`text-2xl ${statusInfo.color} bg-white/80 dark:bg-surface-dark/80 p-2 rounded-full shadow-sm`}>
|
<div className="flex items-center gap-3 md:gap-4 min-w-0 flex-1">
|
||||||
|
<div className={`text-2xl md:text-2xl ${statusInfo.color} bg-white/80 dark:bg-surface-dark/80 p-2 md:p-2 rounded-full shadow-sm flex-shrink-0`}>
|
||||||
{statusInfo.icon}
|
{statusInfo.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow min-w-0">
|
<div className="flex-grow min-w-0">
|
||||||
{item.type === "track" && (
|
{item.type === "track" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FaMusic className="icon-muted text-sm" />
|
<FaMusic className="icon-muted text-sm flex-shrink-0" />
|
||||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
|
<p className="text-sm md:text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
|
||||||
{item.artist}
|
{item.artist}
|
||||||
</p>
|
</p>
|
||||||
{item.albumName && (
|
{item.albumName && (
|
||||||
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.albumName}>
|
<p className="text-xs md:text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.albumName}>
|
||||||
{item.albumName}
|
{item.albumName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -155,16 +156,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
{item.type === "album" && (
|
{item.type === "album" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FaCompactDisc className="icon-muted text-sm" />
|
<FaCompactDisc className="icon-muted text-sm flex-shrink-0" />
|
||||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
|
<p className="text-sm md:text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.artist}>
|
||||||
{item.artist}
|
{item.artist}
|
||||||
</p>
|
</p>
|
||||||
{item.currentTrackTitle && (
|
{item.currentTrackTitle && (
|
||||||
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
|
<p className="text-xs md:text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
|
||||||
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -173,16 +174,16 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
{item.type === "playlist" && (
|
{item.type === "playlist" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FaMusic className="icon-muted text-sm" />
|
<FaMusic className="icon-muted text-sm flex-shrink-0" />
|
||||||
<p className="font-bold text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
<p className="font-bold text-base md:text-sm text-content-primary dark:text-content-primary-dark truncate" title={item.name}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.playlistOwner}>
|
<p className="text-sm md:text-sm text-content-secondary dark:text-content-secondary-dark truncate" title={item.playlistOwner}>
|
||||||
{item.playlistOwner}
|
{item.playlistOwner}
|
||||||
</p>
|
</p>
|
||||||
{item.currentTrackTitle && (
|
{item.currentTrackTitle && (
|
||||||
<p className="text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
|
<p className="text-xs md:text-xs text-content-muted dark:text-content-muted-dark truncate" title={item.currentTrackTitle}>
|
||||||
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
{item.currentTrackNumber}/{item.totalTracks}: {item.currentTrackTitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -190,60 +191,62 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 ml-4">
|
|
||||||
<div className="text-right">
|
{/* Status and actions - stacked on mobile, inline on desktop */}
|
||||||
<div className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${statusInfo.color} bg-white/60 dark:bg-surface-dark/60 shadow-sm`}>
|
<div className="flex items-center justify-between md:justify-end gap-3 md:gap-3 md:ml-4">
|
||||||
|
<div className="flex-1 md:flex-none md:text-right">
|
||||||
|
<div className={`inline-flex items-center px-3 py-1 md:px-3 md:py-1 rounded-full text-sm md:text-xs font-semibold ${statusInfo.color} bg-white/60 dark:bg-surface-dark/60 shadow-sm`}>
|
||||||
{statusInfo.name}
|
{statusInfo.name}
|
||||||
</div>
|
</div>
|
||||||
{progressText && <p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">{progressText}</p>}
|
{progressText && <p className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark mt-1">{progressText}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-2 md:gap-1 flex-shrink-0">
|
||||||
{isTerminal ? (
|
{isTerminal ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeItem?.(item.id)}
|
onClick={() => removeItem?.(item.id)}
|
||||||
className="p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-error hover:bg-error/10 transition-all duration-200 shadow-sm"
|
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-error hover:bg-error/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
|
||||||
aria-label="Remove"
|
aria-label="Remove"
|
||||||
>
|
>
|
||||||
<FaTimes className="text-sm" />
|
<FaTimes className="text-base md:text-sm" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => cancelItem?.(item.id)}
|
onClick={() => cancelItem?.(item.id)}
|
||||||
className="p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-warning hover:bg-warning/10 transition-all duration-200 shadow-sm"
|
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-warning hover:bg-warning/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
|
||||||
aria-label="Cancel"
|
aria-label="Cancel"
|
||||||
>
|
>
|
||||||
<FaTimes className="text-sm" />
|
<FaTimes className="text-base md:text-sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{item.canRetry && (
|
{item.canRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={() => retryItem?.(item.id)}
|
onClick={() => retryItem?.(item.id)}
|
||||||
className="p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-info hover:bg-info/10 transition-all duration-200 shadow-sm"
|
className="p-3 md:p-2 rounded-full bg-white/60 dark:bg-surface-dark/60 text-content-muted dark:text-content-muted-dark hover:text-info hover:bg-info/10 transition-all duration-200 shadow-sm min-h-[44px] md:min-h-auto flex items-center justify-center"
|
||||||
aria-label="Retry"
|
aria-label="Retry"
|
||||||
>
|
>
|
||||||
<FaSync className="text-sm" />
|
<FaSync className="text-base md:text-sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(item.status === "error" || item.status === "retrying") && item.error && (
|
{(item.status === "error" || item.status === "retrying") && item.error && (
|
||||||
<div className="mt-3 p-2 bg-error/10 border border-error/20 rounded-lg">
|
<div className="mt-3 p-3 md:p-2 bg-error/10 border border-error/20 rounded-lg">
|
||||||
<p className="text-xs text-error font-medium">Error: {item.error}</p>
|
<p className="text-sm md:text-xs text-error font-medium break-words">Error: {item.error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
|
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
|
||||||
<div className="mt-3 p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg">
|
<div className="mt-3 p-3 md:p-2 bg-surface/50 dark:bg-surface-dark/50 rounded-lg">
|
||||||
<div className="flex gap-4 text-xs">
|
<div className="flex flex-wrap gap-3 md:gap-4 text-sm md:text-xs">
|
||||||
{item.summary.total_failed > 0 && (
|
{item.summary.total_failed > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-2 md:gap-1">
|
||||||
<div className="w-2 h-2 bg-error rounded-full"></div>
|
<div className="w-3 h-3 md:w-2 md:h-2 bg-error rounded-full flex-shrink-0"></div>
|
||||||
<span className="text-error font-medium">{item.summary.total_failed} failed</span>
|
<span className="text-error font-medium">{item.summary.total_failed} failed</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.summary.total_skipped > 0 && (
|
{item.summary.total_skipped > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-2 md:gap-1">
|
||||||
<div className="w-2 h-2 bg-warning rounded-full"></div>
|
<div className="w-3 h-3 md:w-2 md:h-2 bg-warning rounded-full flex-shrink-0"></div>
|
||||||
<span className="text-warning font-medium">{item.summary.total_skipped} skipped</span>
|
<span className="text-warning font-medium">{item.summary.total_skipped} skipped</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -253,12 +256,12 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
{(item.status === "downloading" || item.status === "processing") &&
|
{(item.status === "downloading" || item.status === "processing") &&
|
||||||
item.type === "track" &&
|
item.type === "track" &&
|
||||||
item.progress !== undefined && (
|
item.progress !== undefined && (
|
||||||
<div className="mt-3">
|
<div className="mt-4 md:mt-3">
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-2 md:mb-1">
|
||||||
<span className="text-xs text-content-muted dark:text-content-muted-dark">Progress</span>
|
<span className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark">Progress</span>
|
||||||
<span className="text-xs font-semibold text-content-primary dark:text-content-primary-dark">{item.progress.toFixed(0)}%</span>
|
<span className="text-sm md:text-xs font-semibold text-content-primary dark:text-content-primary-dark">{item.progress.toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 w-full bg-surface/50 dark:bg-surface-dark/50 rounded-full overflow-hidden">
|
<div className="h-3 md:h-2 w-full bg-surface/50 dark:bg-surface-dark/50 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
||||||
item.status === "downloading" ? "bg-info" : "bg-processing"
|
item.status === "downloading" ? "bg-info" : "bg-processing"
|
||||||
@@ -274,6 +277,9 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
|
|
||||||
export const Queue = () => {
|
export const Queue = () => {
|
||||||
const context = useContext(QueueContext);
|
const context = useContext(QueueContext);
|
||||||
|
const [startY, setStartY] = useState<number | null>(null);
|
||||||
|
const [currentY, setCurrentY] = useState<number | null>(null);
|
||||||
|
const queueRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (!context) return null;
|
if (!context) return null;
|
||||||
const { items, isVisible, toggleVisibility, cancelAll, clearCompleted } = context;
|
const { items, isVisible, toggleVisibility, cancelAll, clearCompleted } = context;
|
||||||
@@ -283,51 +289,107 @@ export const Queue = () => {
|
|||||||
const hasActive = items.some((item) => !isTerminalStatus(item.status));
|
const hasActive = items.some((item) => !isTerminalStatus(item.status));
|
||||||
const hasFinished = items.some((item) => isTerminalStatus(item.status));
|
const hasFinished = items.some((item) => isTerminalStatus(item.status));
|
||||||
|
|
||||||
|
// Handle mobile swipe-to-dismiss
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
setStartY(e.touches[0].clientY);
|
||||||
|
setCurrentY(e.touches[0].clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
if (startY === null) return;
|
||||||
|
setCurrentY(e.touches[0].clientY);
|
||||||
|
|
||||||
|
const deltaY = e.touches[0].clientY - startY;
|
||||||
|
|
||||||
|
// Only allow downward swipes to dismiss
|
||||||
|
if (deltaY > 0) {
|
||||||
|
if (queueRef.current) {
|
||||||
|
queueRef.current.style.transform = `translateY(${Math.min(deltaY, 100)}px)`;
|
||||||
|
queueRef.current.style.opacity = `${Math.max(0.3, 1 - deltaY / 200)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (startY === null || currentY === null) return;
|
||||||
|
|
||||||
|
const deltaY = currentY - startY;
|
||||||
|
|
||||||
|
if (queueRef.current) {
|
||||||
|
queueRef.current.style.transform = '';
|
||||||
|
queueRef.current.style.opacity = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss if swiped down more than 50px
|
||||||
|
if (deltaY > 50) {
|
||||||
|
toggleVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStartY(null);
|
||||||
|
setCurrentY(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 w-full max-w-md bg-surface dark:bg-surface-dark rounded-xl shadow-2xl border border-border dark:border-border-dark z-50 backdrop-blur-sm">
|
<>
|
||||||
<header className="flex items-center justify-between p-4 border-b border-border dark:border-border-dark bg-gradient-to-r from-surface to-surface-secondary dark:from-surface-dark dark:to-surface-secondary-dark rounded-t-xl">
|
{/* Mobile backdrop overlay */}
|
||||||
<h2 className="text-lg font-bold text-content-primary dark:text-content-primary-dark">
|
<div
|
||||||
Download Queue ({items.length})
|
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||||
</h2>
|
onClick={toggleVisibility}
|
||||||
<div className="flex gap-2">
|
/>
|
||||||
<button
|
|
||||||
onClick={cancelAll}
|
<div
|
||||||
className="text-sm text-content-muted dark:text-content-muted-dark hover:text-warning transition-colors px-2 py-1 rounded-md hover:bg-warning/10"
|
ref={queueRef}
|
||||||
disabled={!hasActive}
|
className="fixed inset-x-0 bottom-0 md:bottom-4 md:right-4 md:inset-x-auto w-full md:max-w-md bg-surface dark:bg-surface-dark md:rounded-xl shadow-2xl border-t md:border border-border dark:border-border-dark z-50 backdrop-blur-sm md:rounded-b-xl transition-transform transition-opacity"
|
||||||
aria-label="Cancel all active downloads"
|
onTouchStart={handleTouchStart}
|
||||||
>
|
onTouchMove={handleTouchMove}
|
||||||
Cancel All
|
onTouchEnd={handleTouchEnd}
|
||||||
</button>
|
>
|
||||||
<button
|
<header className="flex items-center justify-between p-4 md:p-4 border-b border-border dark:border-border-dark bg-gradient-to-r from-surface to-surface-secondary dark:from-surface-dark dark:to-surface-secondary-dark md:rounded-t-xl">
|
||||||
onClick={clearCompleted}
|
{/* Add drag indicator for mobile */}
|
||||||
className="text-sm text-content-muted dark:text-content-muted-dark hover:text-success transition-colors px-2 py-1 rounded-md hover:bg-success/10"
|
<div className="md:hidden absolute top-2 left-1/2 transform -translate-x-1/2 w-12 h-1 bg-content-muted dark:bg-content-muted-dark rounded-full opacity-50"></div>
|
||||||
disabled={!hasFinished}
|
<h2 className="text-lg md:text-lg font-bold text-content-primary dark:text-content-primary-dark">
|
||||||
aria-label="Clear all finished downloads"
|
Download Queue ({items.length})
|
||||||
>
|
</h2>
|
||||||
Clear Finished
|
<div className="flex gap-1 md:gap-2">
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={cancelAll}
|
||||||
onClick={toggleVisibility}
|
className="text-xs md:text-sm text-content-muted dark:text-content-muted-dark hover:text-warning transition-colors px-3 py-2 md:px-2 md:py-1 rounded-md hover:bg-warning/10 min-h-[44px] md:min-h-auto"
|
||||||
className="text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark p-1 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors"
|
disabled={!hasActive}
|
||||||
aria-label="Close queue"
|
aria-label="Cancel all active downloads"
|
||||||
>
|
>
|
||||||
<FaTimes className="text-sm" />
|
Cancel All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</header>
|
onClick={clearCompleted}
|
||||||
<div className="p-4 overflow-y-auto max-h-96 bg-gradient-to-b from-surface-secondary/30 to-surface/30 dark:from-surface-secondary-dark/30 dark:to-surface-dark/30">
|
className="text-xs md:text-sm text-content-muted dark:text-content-muted-dark hover:text-success transition-colors px-3 py-2 md:px-2 md:py-1 rounded-md hover:bg-success/10 min-h-[44px] md:min-h-auto"
|
||||||
{items.length === 0 ? (
|
disabled={!hasFinished}
|
||||||
<div className="text-center py-8">
|
aria-label="Clear all finished downloads"
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-surface-muted dark:bg-surface-muted-dark flex items-center justify-center">
|
>
|
||||||
<FaMusic className="text-2xl icon-muted" />
|
Clear Finished
|
||||||
</div>
|
</button>
|
||||||
<p className="text-content-muted dark:text-content-muted-dark">The queue is empty.</p>
|
<button
|
||||||
<p className="text-xs text-content-muted dark:text-content-muted-dark mt-1">Downloads will appear here</p>
|
onClick={toggleVisibility}
|
||||||
|
className="text-content-muted dark:text-content-muted-dark hover:text-content-primary dark:hover:text-content-primary-dark p-3 md:p-1 rounded-md hover:bg-surface-muted dark:hover:bg-surface-muted-dark transition-colors min-h-[44px] md:min-h-auto flex items-center justify-center"
|
||||||
|
aria-label="Close queue"
|
||||||
|
>
|
||||||
|
<FaTimes className="text-base md:text-sm" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</header>
|
||||||
items.map((item) => <QueueItemCard key={item.id} item={item} />)
|
<div className="p-4 overflow-y-auto max-h-[60vh] md:max-h-96 bg-gradient-to-b from-surface-secondary/30 to-surface/30 dark:from-surface-secondary-dark/30 dark:to-surface-dark/30">
|
||||||
)}
|
{items.length === 0 ? (
|
||||||
|
<div className="text-center py-8 md:py-8">
|
||||||
|
<div className="w-20 h-20 md:w-16 md:h-16 mx-auto mb-4 rounded-full bg-surface-muted dark:bg-surface-muted-dark flex items-center justify-center">
|
||||||
|
<FaMusic className="text-3xl md:text-2xl icon-muted" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base md:text-sm text-content-muted dark:text-content-muted-dark">The queue is empty.</p>
|
||||||
|
<p className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark mt-1">Downloads will appear here</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => <QueueItemCard key={item.id} item={item} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||