v6.8
This commit is contained in:
@@ -52,6 +52,7 @@ type DownloadRequest struct {
|
|||||||
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
|
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
|
||||||
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
||||||
EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file
|
EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file
|
||||||
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` // Whether to embed max quality cover art
|
||||||
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
||||||
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
|
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
|
||||||
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
|
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
|
||||||
|
|||||||
+8
-1
@@ -350,7 +350,14 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
var lyrics string
|
var lyrics string
|
||||||
|
|
||||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||||
lyrics, _ = ExtractLyrics(inputFile)
|
lyrics, err = ExtractLyrics(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||||
|
} else if lyrics != "" {
|
||||||
|
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
|
||||||
|
}
|
||||||
|
|
||||||
// Build ffmpeg command
|
// Build ffmpeg command
|
||||||
args := []string{
|
args := []string{
|
||||||
|
|||||||
+13
-6
@@ -54,9 +54,12 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
|
|||||||
|
|
||||||
// sanitizeFilename removes invalid characters from filename
|
// sanitizeFilename removes invalid characters from filename
|
||||||
func sanitizeFilename(name string) string {
|
func sanitizeFilename(name string) string {
|
||||||
// First, remove invalid filesystem characters
|
// Replace forward slash with space (more natural than underscore)
|
||||||
re := regexp.MustCompile(`[<>:"/\\|?*]`)
|
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||||
sanitized := re.ReplaceAllString(name, "_")
|
|
||||||
|
// Remove other invalid filesystem characters (replace with space)
|
||||||
|
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||||
|
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||||
|
|
||||||
// Remove control characters (0x00-0x1F, 0x7F)
|
// Remove control characters (0x00-0x1F, 0x7F)
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
@@ -94,11 +97,15 @@ func sanitizeFilename(name string) string {
|
|||||||
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
|
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
|
||||||
sanitized = strings.Trim(sanitized, ". ")
|
sanitized = strings.Trim(sanitized, ". ")
|
||||||
|
|
||||||
// Remove consecutive spaces and underscores
|
// Normalize consecutive spaces to single space
|
||||||
re = regexp.MustCompile(`[\s_]+`)
|
re = regexp.MustCompile(`\s+`)
|
||||||
|
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||||
|
|
||||||
|
// Normalize consecutive underscores to single underscore
|
||||||
|
re = regexp.MustCompile(`_+`)
|
||||||
sanitized = re.ReplaceAllString(sanitized, "_")
|
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
// Remove leading/trailing underscores
|
// Remove leading/trailing underscores and spaces
|
||||||
sanitized = strings.Trim(sanitized, "_ ")
|
sanitized = strings.Trim(sanitized, "_ ")
|
||||||
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
|
|||||||
+67
-2
@@ -3,6 +3,7 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
pathfilepath "path/filepath"
|
pathfilepath "path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -364,14 +365,22 @@ func extractLyricsFromMp3(filePath string) (string, error) {
|
|||||||
|
|
||||||
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
||||||
if len(usltFrames) == 0 {
|
if len(usltFrames) == 0 {
|
||||||
|
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
|
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if uslt.Lyrics == "" {
|
||||||
|
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
|
||||||
return uslt.Lyrics, nil
|
return uslt.Lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,13 +404,16 @@ func extractLyricsFromFlac(filePath string) (string, error) {
|
|||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
fieldName := strings.ToUpper(parts[0])
|
fieldName := strings.ToUpper(parts[0])
|
||||||
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
|
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
|
||||||
return parts[1], nil
|
lyrics := parts[1]
|
||||||
|
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
|
||||||
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,7 +504,58 @@ func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedLyricsOnlyUniversal embeds lyrics to MP3 or FLAC file
|
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
|
||||||
|
func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||||
|
// Use ffmpeg to embed lyrics into M4A file
|
||||||
|
// M4A uses iTunes metadata format with atom '©lyr' for lyrics
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary output file with proper extension so ffmpeg can detect format
|
||||||
|
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
|
||||||
|
defer func() {
|
||||||
|
// Only remove if file still exists (rename might have failed)
|
||||||
|
if _, err := os.Stat(tmpOutputFile); err == nil {
|
||||||
|
os.Remove(tmpOutputFile)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Use ffmpeg to copy file and add lyrics metadata
|
||||||
|
// For M4A, we need to use the correct metadata tag format and specify output format
|
||||||
|
// Use -f ipod for M4A format (iPod format is compatible with M4A)
|
||||||
|
cmd := exec.Command(ffmpegPath,
|
||||||
|
"-i", filepath,
|
||||||
|
"-map", "0",
|
||||||
|
"-map_metadata", "0",
|
||||||
|
"-metadata", "lyrics-eng="+lyrics,
|
||||||
|
"-metadata", "lyrics="+lyrics,
|
||||||
|
"-codec", "copy",
|
||||||
|
"-f", "ipod", // Explicitly specify M4A/iPod format
|
||||||
|
"-y", // Overwrite
|
||||||
|
tmpOutputFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hide console window on Windows
|
||||||
|
setHideWindow(cmd)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
|
||||||
|
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace original file with new file
|
||||||
|
if err := os.Rename(tmpOutputFile, filepath); err != nil {
|
||||||
|
return fmt.Errorf("failed to replace original file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
|
||||||
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
||||||
if lyrics == "" {
|
if lyrics == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -504,6 +567,8 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
|||||||
return EmbedLyricsOnlyMP3(filepath, lyrics)
|
return EmbedLyricsOnlyMP3(filepath, lyrics)
|
||||||
case ".flac":
|
case ".flac":
|
||||||
return EmbedLyricsOnly(filepath, lyrics)
|
return EmbedLyricsOnly(filepath, lyrics)
|
||||||
|
case ".m4a":
|
||||||
|
return embedLyricsToM4A(filepath, lyrics)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
|
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/@vercel/geist-font@latest/css/geist-sans.css" rel="stylesheet">
|
|
||||||
<title>SpotiFLAC</title>
|
<title>SpotiFLAC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
39c59c100ededac1c1e21fae937a2755
|
7d8d0f3230f9ffbfba312943439831fc
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -17,13 +17,15 @@ interface AudioAnalysisProps {
|
|||||||
analyzing: boolean;
|
analyzing: boolean;
|
||||||
onAnalyze?: () => void;
|
onAnalyze?: () => void;
|
||||||
showAnalyzeButton?: boolean;
|
showAnalyzeButton?: boolean;
|
||||||
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioAnalysis({
|
export function AudioAnalysis({
|
||||||
result,
|
result,
|
||||||
analyzing,
|
analyzing,
|
||||||
onAnalyze,
|
onAnalyze,
|
||||||
showAnalyzeButton = true
|
showAnalyzeButton = true,
|
||||||
|
filePath
|
||||||
}: AudioAnalysisProps) {
|
}: AudioAnalysisProps) {
|
||||||
if (analyzing) {
|
if (analyzing) {
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +45,7 @@ export function AudioAnalysis({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||||
<Activity className="h-12 w-12 text-muted-foreground/50" />
|
<Activity className="h-12 w-12 text-primary" />
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="font-medium">Audio Quality Analysis</p>
|
<p className="font-medium">Audio Quality Analysis</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -82,86 +84,61 @@ export function AudioAnalysis({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
{filePath && (
|
||||||
<Activity className="h-5 w-5" />
|
<p className="text-sm font-mono truncate">{filePath}</p>
|
||||||
Audio Quality Analysis
|
)}
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="px-6 space-y-6">
|
<CardContent className="space-y-2">
|
||||||
|
{/* Audio Properties - Single line */}
|
||||||
{/* Technical Specifications */}
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Sample Rate:</span>
|
||||||
<Radio className="h-3 w-3" />
|
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||||
Sample Rate
|
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</p>
|
<div className="flex items-center gap-1">
|
||||||
|
<FileAudio className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Bit Depth:</span>
|
||||||
|
<span className="font-semibold">{result.bit_depth}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<Waves className="h-3 w-3 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Channels:</span>
|
||||||
<FileAudio className="h-3 w-3" />
|
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||||
Bit Depth
|
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold">{result.bit_depth}</p>
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
|
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Nyquist:</span>
|
||||||
<Waves className="h-3 w-3" />
|
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||||
Channels
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
Duration
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{formatDuration(result.duration)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Gauge className="h-3 w-3" />
|
|
||||||
Nyquist Frequency
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Range Analysis */}
|
{/* Dynamic Range - Single line */}
|
||||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-1">
|
||||||
<TrendingUp className="h-4 w-4" />
|
<TrendingUp className="h-3 w-3 text-muted-foreground" />
|
||||||
Dynamic Range Analysis
|
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||||
|
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
<span className="text-muted-foreground">Peak:</span>
|
||||||
<div>
|
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
||||||
<p className="text-xs text-muted-foreground">Dynamic Range</p>
|
|
||||||
<p className="font-semibold">{formatNumber(result.dynamic_range)} dB</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-1">
|
||||||
<p className="text-xs text-muted-foreground">Peak Level</p>
|
<span className="text-muted-foreground">RMS:</span>
|
||||||
<p className="font-semibold">{formatNumber(result.peak_amplitude)} dB</p>
|
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
<p className="text-xs text-muted-foreground">RMS Level</p>
|
<span className="text-muted-foreground">Samples:</span>
|
||||||
<p className="font-semibold">{formatNumber(result.rms_level)} dB</p>
|
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Technical Info Footer */}
|
|
||||||
<div className="pt-2 border-t">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Total Samples: {result.total_samples.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload, ArrowLeft } from "lucide-react";
|
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
||||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||||
@@ -13,15 +13,13 @@ interface AudioAnalysisPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
|
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
const handleSelectFile = async () => {
|
||||||
try {
|
try {
|
||||||
const filePath = await SelectFile();
|
const filePath = await SelectFile();
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
setSelectedFilePath(filePath);
|
|
||||||
await analyzeFile(filePath);
|
await analyzeFile(filePath);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -46,7 +44,6 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedFilePath(filePath);
|
|
||||||
await analyzeFile(filePath);
|
await analyzeFile(filePath);
|
||||||
},
|
},
|
||||||
[analyzeFile]
|
[analyzeFile]
|
||||||
@@ -64,12 +61,12 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
|
|
||||||
const handleAnalyzeAnother = () => {
|
const handleAnalyzeAnother = () => {
|
||||||
clearResult();
|
clearResult();
|
||||||
setSelectedFilePath("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
@@ -78,6 +75,13 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
)}
|
)}
|
||||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{result && (
|
||||||
|
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* File Selection */}
|
{/* File Selection */}
|
||||||
{!result && !analyzing && (
|
{!result && !analyzing && (
|
||||||
@@ -102,7 +106,7 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
<Upload className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
{isDragging
|
{isDragging
|
||||||
@@ -127,25 +131,23 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
{/* Analysis Results */}
|
{/* Analysis Results */}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* File Info with Analyze Another Button */}
|
{/* Detailed Analysis */}
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath} />
|
||||||
<p className="text-sm font-mono truncate flex-1 min-w-0">{selectedFilePath}</p>
|
|
||||||
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm" className="ml-4 shrink-0">
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
Analyze Another File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spectrum Visualization */}
|
{/* Spectrum Visualization */}
|
||||||
|
{spectrumLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<SpectrumVisualization
|
<SpectrumVisualization
|
||||||
sampleRate={result.sample_rate}
|
sampleRate={result.sample_rate}
|
||||||
bitsPerSample={result.bits_per_sample}
|
bitsPerSample={result.bits_per_sample}
|
||||||
duration={result.duration}
|
duration={result.duration}
|
||||||
spectrumData={result.spectrum}
|
spectrumData={result.spectrum}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{/* Detailed Analysis */}
|
|
||||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Trash2,
|
Trash2,
|
||||||
FileMusic,
|
FileMusic,
|
||||||
|
WandSparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
@@ -113,6 +114,19 @@ export function AudioConverterPage() {
|
|||||||
saveState({ files, outputFormat, bitrate });
|
saveState({ files, outputFormat, bitrate });
|
||||||
}, [files, outputFormat, bitrate, saveState]);
|
}, [files, outputFormat, bitrate, saveState]);
|
||||||
|
|
||||||
|
// Auto-set output format to M4A if all files are MP3
|
||||||
|
useEffect(() => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
const allMP3 = files.every((f) => f.format === "mp3");
|
||||||
|
if (allMP3 && outputFormat !== "m4a") {
|
||||||
|
setOutputFormat("m4a");
|
||||||
|
}
|
||||||
|
}, [files, outputFormat]);
|
||||||
|
|
||||||
|
// Check if format selection should be disabled (all files are MP3)
|
||||||
|
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
|
||||||
|
|
||||||
// Detect fullscreen/maximized window
|
// Detect fullscreen/maximized window
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkFullscreen = () => {
|
const checkFullscreen = () => {
|
||||||
@@ -236,7 +250,20 @@ export function AudioConverterPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addFiles = useCallback((paths: string[]) => {
|
const addFiles = useCallback((paths: string[]) => {
|
||||||
const validExtensions = [".mp3", ".m4a", ".flac"];
|
const validExtensions = [".mp3", ".flac"];
|
||||||
|
|
||||||
|
// Check for M4A files specifically
|
||||||
|
const m4aFiles = paths.filter((path) => {
|
||||||
|
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||||
|
return ext === ".m4a";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (m4aFiles.length > 0) {
|
||||||
|
toast.error("M4A files not supported", {
|
||||||
|
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
const newFiles: AudioFile[] = paths
|
const newFiles: AudioFile[] = paths
|
||||||
.filter((path) => {
|
.filter((path) => {
|
||||||
@@ -266,7 +293,7 @@ export function AudioConverterPage() {
|
|||||||
return [...prev, ...newFiles];
|
return [...prev, ...newFiles];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paths.length > 0) {
|
if (paths.length > 0 && m4aFiles.length === 0) {
|
||||||
toast.info("No new files added", {
|
toast.info("No new files added", {
|
||||||
description: "All files were already added or have unsupported format",
|
description: "All files were already added or have unsupported format",
|
||||||
});
|
});
|
||||||
@@ -462,8 +489,25 @@ export function AudioConverterPage() {
|
|||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Add More
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFiles}
|
||||||
|
disabled={converting}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop Zone / File List */}
|
{/* Drop Zone / File List */}
|
||||||
@@ -492,7 +536,7 @@ export function AudioConverterPage() {
|
|||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
<Upload className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-2 text-center">
|
<p className="text-sm text-muted-foreground mb-2 text-center">
|
||||||
{isDragging
|
{isDragging
|
||||||
@@ -500,7 +544,7 @@ export function AudioConverterPage() {
|
|||||||
: "Drag and drop audio files here, or click the button below to select"}
|
: "Drag and drop audio files here, or click the button below to select"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mb-4 text-center">
|
<p className="text-xs text-muted-foreground mb-4 text-center">
|
||||||
Supported formats: MP3, M4A, FLAC
|
Supported formats: FLAC, MP3
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleSelectFiles} size="lg">
|
<Button onClick={handleSelectFiles} size="lg">
|
||||||
<Upload className="h-5 w-5" />
|
<Upload className="h-5 w-5" />
|
||||||
@@ -520,13 +564,16 @@ export function AudioConverterPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
value={outputFormat}
|
value={outputFormat}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value) setOutputFormat(value as "mp3" | "m4a");
|
if (value && !isFormatDisabled) setOutputFormat(value as "mp3" | "m4a");
|
||||||
}}
|
}}
|
||||||
|
disabled={isFormatDisabled}
|
||||||
>
|
>
|
||||||
|
{!isFormatDisabled && (
|
||||||
<ToggleGroupItem value="mp3" aria-label="MP3">
|
<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||||
MP3
|
MP3
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
<ToggleGroupItem value="m4a" aria-label="M4A">
|
)}
|
||||||
|
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||||
M4A
|
M4A
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
@@ -552,21 +599,6 @@ export function AudioConverterPage() {
|
|||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-auto">
|
|
||||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
Add More
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearFiles}
|
|
||||||
disabled={converting}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -625,7 +657,7 @@ export function AudioConverterPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FileMusic className="h-4 w-4" />
|
<WandSparkles className="h-4 w-4" />
|
||||||
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function SearchBar({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Supports track, album, playlist, and artist URLs</p>
|
<p>Supports track, album, playlist, and artist URLs</p>
|
||||||
|
<p className="mt-1">Note: Playlist must be public (not private)</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -279,7 +279,8 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Embed Lyrics */}
|
{/* Embed Lyrics & Embed Max Quality Cover */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -288,8 +289,17 @@ export function SettingsPage() {
|
|||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
|
||||||
|
<Switch
|
||||||
|
id="embed-max-quality-cover"
|
||||||
|
checked={tempSettings.embedMaxQualityCover}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-2" />
|
<div className="border-t" />
|
||||||
|
|
||||||
{/* Folder Structure */}
|
{/* Folder Structure */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -339,7 +349,7 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-2" />
|
<div className="border-t" />
|
||||||
|
|
||||||
{/* Filename Format */}
|
{/* Filename Format */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,13 +1,63 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "spotiflac_audio_analysis_state";
|
||||||
|
|
||||||
export function useAudioAnalysis() {
|
export function useAudioAnalysis() {
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const [result, setResult] = useState<AnalysisResult | null>(null);
|
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
||||||
|
// Load from sessionStorage on mount - only detail, no spectrum
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.filePath && parsed.result) {
|
||||||
|
// Return result WITHOUT spectrum - spectrum will be loaded async
|
||||||
|
return {
|
||||||
|
...parsed.result,
|
||||||
|
spectrum: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load saved analysis state:", err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
||||||
|
// Load file path from sessionStorage
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
return parsed.filePath || "";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
||||||
|
// If result exists from sessionStorage, show loading for spectrum
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.filePath && parsed.result) {
|
||||||
|
// Always show loading initially, will be resolved async
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
const analyzeFile = useCallback(async (filePath: string) => {
|
const analyzeFile = useCallback(async (filePath: string) => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -18,6 +68,7 @@ export function useAudioAnalysis() {
|
|||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setSelectedFilePath(filePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Analyzing audio file: ${filePath}`);
|
logger.info(`Analyzing audio file: ${filePath}`);
|
||||||
@@ -29,7 +80,24 @@ export function useAudioAnalysis() {
|
|||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
|
|
||||||
|
// Save spectrum to memory cache
|
||||||
|
if (analysisResult.spectrum) {
|
||||||
|
setSpectrumCache(filePath, analysisResult.spectrum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save detail (without spectrum) to sessionStorage
|
||||||
|
const { spectrum, ...detailResult } = analysisResult;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
filePath,
|
||||||
|
result: detailResult,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save analysis state:", err);
|
||||||
|
}
|
||||||
|
|
||||||
setResult(analysisResult);
|
setResult(analysisResult);
|
||||||
|
setSpectrumLoading(false); // Spectrum is now available
|
||||||
|
|
||||||
return analysisResult;
|
return analysisResult;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -48,12 +116,56 @@ export function useAudioAnalysis() {
|
|||||||
const clearResult = useCallback(() => {
|
const clearResult = useCallback(() => {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSelectedFilePath("");
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
clearSpectrumCache();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load spectrum from cache asynchronously after detail is displayed
|
||||||
|
useEffect(() => {
|
||||||
|
// Only load spectrum if we have result without spectrum and are in loading state
|
||||||
|
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load spectrum asynchronously to avoid blocking UI
|
||||||
|
// Use requestAnimationFrame to ensure detail renders first
|
||||||
|
let rafId: number;
|
||||||
|
const loadSpectrum = () => {
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
||||||
|
if (cachedSpectrum) {
|
||||||
|
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
} else {
|
||||||
|
// Spectrum not in cache - user needs to re-analyze
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Double RAF to ensure detail is fully rendered
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(loadSpectrum);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [result, selectedFilePath, spectrumLoading]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
analyzing,
|
analyzing,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
|
selectedFilePath,
|
||||||
|
spectrumLoading,
|
||||||
analyzeFile,
|
analyzeFile,
|
||||||
clearResult,
|
clearResult,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,18 +40,19 @@ export function useCover() {
|
|||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
// Build output path using template system
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName,
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
album: albumName,
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
title: trackName,
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
playlist: playlistName,
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
|
|
||||||
// For playlist/discography, prepend the folder name
|
// For playlist/discography, prepend the folder name
|
||||||
if (playlistName) {
|
if (playlistName) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply folder template
|
// Apply folder template
|
||||||
@@ -60,7 +61,9 @@ export function useCover() {
|
|||||||
if (folderPath) {
|
if (folderPath) {
|
||||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
// Restore any slashes that were in the original values as spaces
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,18 +143,20 @@ export function useCover() {
|
|||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
// Build output path using template system
|
// Build output path using template system
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: track.artists,
|
artist: track.artists?.replace(/\//g, placeholder),
|
||||||
album: track.album_name,
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
title: track.name,
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
track: i + 1,
|
track: i + 1,
|
||||||
playlist: playlistName,
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
|
|
||||||
// For playlist/discography, prepend the folder name
|
// For playlist/discography, prepend the folder name
|
||||||
if (playlistName) {
|
if (playlistName) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply folder template
|
// Apply folder template
|
||||||
@@ -160,7 +165,9 @@ export function useCover() {
|
|||||||
if (folderPath) {
|
if (folderPath) {
|
||||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
// Restore any slashes that were in the original values as spaces
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,20 +41,22 @@ export function useDownload() {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
let useAlbumTrackNumber = false;
|
let useAlbumTrackNumber = false;
|
||||||
|
|
||||||
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
// Build template data for folder path
|
// Build template data for folder path
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName,
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
album: albumName,
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
title: trackName,
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: releaseYear,
|
year: releaseYear,
|
||||||
playlist: playlistName,
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
isrc: isrc,
|
isrc: isrc,
|
||||||
};
|
};
|
||||||
|
|
||||||
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
||||||
if (playlistName) {
|
if (playlistName) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply folder template if available
|
// Apply folder template if available
|
||||||
@@ -63,7 +65,9 @@ export function useDownload() {
|
|||||||
if (folderPath) {
|
if (folderPath) {
|
||||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
// Restore any slashes that were in the original values as spaces
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +116,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.tidal_url,
|
service_url: streamingURLs.tidal_url,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID, // Pass the same itemID through all attempts
|
item_id: itemID, // Pass the same itemID through all attempts
|
||||||
@@ -146,6 +151,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.deezer_url,
|
service_url: streamingURLs.deezer_url,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
});
|
});
|
||||||
@@ -178,6 +184,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.amazon_url,
|
service_url: streamingURLs.amazon_url,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
});
|
});
|
||||||
@@ -208,6 +215,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
||||||
@@ -285,20 +293,21 @@ export function useDownload() {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
let useAlbumTrackNumber = false;
|
let useAlbumTrackNumber = false;
|
||||||
|
|
||||||
// Build template data for folder path
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName,
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
album: albumName,
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
title: trackName,
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: releaseYear,
|
year: releaseYear,
|
||||||
playlist: folderName,
|
playlist: folderName?.replace(/\//g, placeholder),
|
||||||
isrc: isrc,
|
isrc: isrc,
|
||||||
};
|
};
|
||||||
|
|
||||||
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
||||||
if (folderName && !isAlbum) {
|
if (folderName && !isAlbum) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName, os));
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply folder template if available
|
// Apply folder template if available
|
||||||
@@ -306,10 +315,12 @@ export function useDownload() {
|
|||||||
// Parse and apply folder template
|
// Parse and apply folder template
|
||||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||||
if (folderPath) {
|
if (folderPath) {
|
||||||
// Split by / and sanitize each part
|
// Split by / (template separators), then restore placeholders as spaces
|
||||||
const parts = folderPath.split("/").filter(p => p.trim());
|
const parts = folderPath.split("/").filter(p => p.trim());
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
// Restore any slashes that were in the original values as spaces
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +363,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.tidal_url,
|
service_url: streamingURLs.tidal_url,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
@@ -383,6 +395,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.deezer_url,
|
service_url: streamingURLs.deezer_url,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
});
|
});
|
||||||
@@ -412,6 +425,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.amazon_url,
|
service_url: streamingURLs.amazon_url,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
});
|
});
|
||||||
@@ -439,6 +453,7 @@ export function useDownload() {
|
|||||||
use_album_track_number: useAlbumTrackNumber,
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
audio_format: settings.qobuzQuality || "6", // Use default 6 (16-bit) for auto mode
|
||||||
|
|||||||
@@ -37,17 +37,19 @@ export function useLyrics() {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
// Build output path using template system
|
// Build output path using template system
|
||||||
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName,
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
album: albumName,
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
title: trackName,
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
playlist: playlistName,
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
|
|
||||||
// For playlist/discography, prepend the folder name
|
// For playlist/discography, prepend the folder name
|
||||||
if (playlistName) {
|
if (playlistName) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply folder template
|
// Apply folder template
|
||||||
@@ -56,7 +58,9 @@ export function useLyrics() {
|
|||||||
if (folderPath) {
|
if (folderPath) {
|
||||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
// Restore any slashes that were in the original values as spaces
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,18 +140,20 @@ export function useLyrics() {
|
|||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
// Build output path using template system
|
// Build output path using template system
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: track.artists,
|
artist: track.artists?.replace(/\//g, placeholder),
|
||||||
album: track.album_name,
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
title: track.name,
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
track: track.track_number,
|
track: track.track_number,
|
||||||
playlist: playlistName,
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
|
|
||||||
// For playlist/discography, prepend the folder name
|
// For playlist/discography, prepend the folder name
|
||||||
if (playlistName) {
|
if (playlistName) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply folder template
|
// Apply folder template
|
||||||
@@ -156,7 +162,9 @@ export function useLyrics() {
|
|||||||
if (folderPath) {
|
if (folderPath) {
|
||||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
// Restore any slashes that were in the original values as spaces
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,54 @@
|
|||||||
@apply text-blue-600;
|
@apply text-blue-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure description text uses same color as title */
|
||||||
|
[data-sonner-toast] [data-description],
|
||||||
|
[data-sonner-toast] [data-description] * {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific color for each toast type - match icon color */
|
||||||
|
[data-sonner-toast][data-type="success"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
|
color: rgb(22 163 74) !important; /* green-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast][data-type="error"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
|
color: rgb(220 38 38) !important; /* red-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast][data-type="warning"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
|
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast][data-type="info"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
|
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode - use same icon colors */
|
||||||
|
.dark [data-sonner-toast][data-type="success"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
|
color: rgb(22 163 74) !important; /* green-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-sonner-toast][data-type="error"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
|
color: rgb(220 38 38) !important; /* red-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-sonner-toast][data-type="warning"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
|
color: rgb(202 138 4) !important; /* yellow-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-sonner-toast][data-type="info"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
|
color: rgb(37 99 235) !important; /* blue-600 */
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode toast styling */
|
/* Dark mode toast styling */
|
||||||
.dark [data-sonner-toast][data-type="success"] {
|
.dark [data-sonner-toast][data-type="success"] {
|
||||||
@apply bg-green-950 border-green-800 text-green-100;
|
@apply bg-green-950 border-green-800 text-green-100;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface Settings {
|
|||||||
trackNumber: boolean;
|
trackNumber: boolean;
|
||||||
sfxEnabled: boolean;
|
sfxEnabled: boolean;
|
||||||
embedLyrics: boolean;
|
embedLyrics: boolean;
|
||||||
|
embedMaxQualityCover: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
// Quality settings for specific sources
|
// Quality settings for specific sources
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
@@ -86,6 +87,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
trackNumber: false,
|
trackNumber: false,
|
||||||
sfxEnabled: true,
|
sfxEnabled: true,
|
||||||
embedLyrics: false,
|
embedLyrics: false,
|
||||||
|
embedMaxQualityCover: false,
|
||||||
operatingSystem: detectOS(),
|
operatingSystem: detectOS(),
|
||||||
tidalQuality: "LOSSLESS", // Default: 16-bit lossless
|
tidalQuality: "LOSSLESS", // Default: 16-bit lossless
|
||||||
qobuzQuality: "6" // Default: FLAC 16-bit
|
qobuzQuality: "6" // Default: FLAC 16-bit
|
||||||
@@ -94,7 +96,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
|
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
|
||||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist Sans", system-ui, sans-serif' },
|
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||||
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
|
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
|
||||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Memory cache for spectrum data (fast access, cleared on page refresh)
|
||||||
|
// Key: file path, Value: spectrum data
|
||||||
|
|
||||||
|
const spectrumCache = new Map<string, any>();
|
||||||
|
|
||||||
|
export function setSpectrumCache(filePath: string, spectrumData: any): void {
|
||||||
|
spectrumCache.set(filePath, spectrumData);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpectrumCache(filePath: string): any | null {
|
||||||
|
return spectrumCache.get(filePath) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSpectrumCache(filePath?: string): void {
|
||||||
|
if (filePath) {
|
||||||
|
spectrumCache.delete(filePath);
|
||||||
|
} else {
|
||||||
|
spectrumCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -124,6 +124,7 @@ export interface DownloadRequest {
|
|||||||
use_album_track_number?: boolean;
|
use_album_track_number?: boolean;
|
||||||
spotify_id?: string;
|
spotify_id?: string;
|
||||||
embed_lyrics?: boolean; // Whether to embed lyrics into the audio file
|
embed_lyrics?: boolean; // Whether to embed lyrics into the audio file
|
||||||
|
embed_max_quality_cover?: boolean; // Whether to embed max quality cover art
|
||||||
service_url?: string;
|
service_url?: string;
|
||||||
duration?: number; // Track duration in seconds for better matching
|
duration?: number; // Track duration in seconds for better matching
|
||||||
item_id?: string; // Optional queue item ID for multi-service fallback tracking
|
item_id?: string; // Optional queue item ID for multi-service fallback tracking
|
||||||
|
|||||||
Reference in New Issue
Block a user