v6.6
This commit is contained in:
@@ -482,6 +482,48 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
|
|||||||
return *resp, nil
|
return *resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoverDownloadRequest represents the request structure for downloading cover art
|
||||||
|
type CoverDownloadRequest struct {
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
TrackNumber bool `json:"track_number"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadCover downloads cover art for a single track
|
||||||
|
func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) {
|
||||||
|
if req.CoverURL == "" {
|
||||||
|
return backend.CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Cover URL is required",
|
||||||
|
}, fmt.Errorf("cover URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := backend.NewCoverClient()
|
||||||
|
backendReq := backend.CoverDownloadRequest{
|
||||||
|
CoverURL: req.CoverURL,
|
||||||
|
TrackName: req.TrackName,
|
||||||
|
ArtistName: req.ArtistName,
|
||||||
|
OutputDir: req.OutputDir,
|
||||||
|
FilenameFormat: req.FilenameFormat,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
Position: req.Position,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.DownloadCover(backendReq)
|
||||||
|
if err != nil {
|
||||||
|
return backend.CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return *resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckTrackAvailability checks the availability of a track on different streaming platforms
|
// CheckTrackAvailability checks the availability of a track on different streaming platforms
|
||||||
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
|
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Spotify image size codes
|
||||||
|
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||||
|
spotifySizeMax = "ab67616d000082c1" // Max resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoverDownloadRequest represents a request to download cover art
|
||||||
|
type CoverDownloadRequest struct {
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
TrackNumber bool `json:"track_number"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverDownloadResponse represents the response from cover download
|
||||||
|
type CoverDownloadResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoverClient handles cover art downloading
|
||||||
|
type CoverClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCoverClient creates a new cover client
|
||||||
|
func NewCoverClient() *CoverClient {
|
||||||
|
return &CoverClient{
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCoverFilename builds the cover filename based on settings (same as track filename)
|
||||||
|
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
|
||||||
|
safeTitle := sanitizeFilename(trackName)
|
||||||
|
safeArtist := sanitizeFilename(artistName)
|
||||||
|
|
||||||
|
var filename string
|
||||||
|
|
||||||
|
// Build base filename based on format
|
||||||
|
switch filenameFormat {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||||
|
case "title":
|
||||||
|
filename = safeTitle
|
||||||
|
default: // "title-artist"
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add track number prefix if enabled
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename + ".jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMaxResolutionURL converts a Spotify cover URL to max resolution
|
||||||
|
// Falls back to original URL if max resolution is not available
|
||||||
|
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
|
||||||
|
// Try to convert to max resolution
|
||||||
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
|
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
|
// Check if max resolution URL is available
|
||||||
|
resp, err := c.httpClient.Head(maxURL)
|
||||||
|
if err == nil && resp.StatusCode == http.StatusOK {
|
||||||
|
return maxURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return original URL as fallback
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadCover downloads cover art for a single track
|
||||||
|
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||||
|
if req.CoverURL == "" {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Cover URL is required",
|
||||||
|
}, fmt.Errorf("cover URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory if it doesn't exist
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = GetDefaultMusicPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create output directory: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate filename using same format as track
|
||||||
|
filenameFormat := req.FilenameFormat
|
||||||
|
if filenameFormat == "" {
|
||||||
|
filenameFormat = "title-artist" // default
|
||||||
|
}
|
||||||
|
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
|
||||||
|
filePath := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
|
// Check if file already exists
|
||||||
|
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Cover file already exists",
|
||||||
|
File: filePath,
|
||||||
|
AlreadyExists: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get max resolution URL, fallback to original
|
||||||
|
downloadURL := c.getMaxResolutionURL(req.CoverURL)
|
||||||
|
|
||||||
|
// Download cover image
|
||||||
|
resp, err := c.httpClient.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download cover: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
|
||||||
|
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Write content to file
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to write cover file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Cover downloaded successfully",
|
||||||
|
File: filePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +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=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&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=Manrope:wght@300..800&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||||
<title>SpotiFLAC</title>
|
<title>SpotiFLAC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -21,12 +21,13 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.6",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.47.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
23a60910537eca7800052fa01bf45b7a
|
b7a549e463d5f6a2fad25f5ce939cdd7
|
||||||
Generated
+407
-497
File diff suppressed because it is too large
Load Diff
+201
-147
@@ -11,13 +11,14 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { getSettings, applyThemeMode } from "@/lib/settings";
|
import { getSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||||
import { applyTheme } from "@/lib/themes";
|
import { applyTheme } from "@/lib/themes";
|
||||||
import { OpenFolder } from "../wailsjs/go/main/App";
|
import { OpenFolder } from "../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { TitleBar } from "@/components/TitleBar";
|
import { TitleBar } from "@/components/TitleBar";
|
||||||
|
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
import { SearchBar } from "@/components/SearchBar";
|
import { SearchBar } from "@/components/SearchBar";
|
||||||
import { TrackInfo } from "@/components/TrackInfo";
|
import { TrackInfo } from "@/components/TrackInfo";
|
||||||
@@ -27,43 +28,48 @@ import { ArtistInfo } from "@/components/ArtistInfo";
|
|||||||
import { DownloadQueue } from "@/components/DownloadQueue";
|
import { DownloadQueue } from "@/components/DownloadQueue";
|
||||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||||
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
import { useDownload } from "@/hooks/useDownload";
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
import { useLyrics } from "@/hooks/useLyrics";
|
import { useLyrics } from "@/hooks/useLyrics";
|
||||||
|
import { useCover } from "@/hooks/useCover";
|
||||||
import { useAvailability } from "@/hooks/useAvailability";
|
import { useAvailability } from "@/hooks/useAvailability";
|
||||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||||
|
|
||||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||||
const MAX_HISTORY = 5;
|
const MAX_HISTORY = 5;
|
||||||
|
|
||||||
type PageType = "main" | "audio-analysis";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentPageView, setCurrentPageView] = useState<PageType>("main");
|
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [sortBy, setSortBy] = useState<string>("default");
|
const [sortBy, setSortBy] = useState<string>("default");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentListPage, setCurrentListPage] = useState(1);
|
||||||
const [hasUpdate, setHasUpdate] = useState(false);
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
|
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
||||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "6.5";
|
const CURRENT_VERSION = "6.6";
|
||||||
|
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
const lyrics = useLyrics();
|
const lyrics = useLyrics();
|
||||||
|
const cover = useCover();
|
||||||
const availability = useAvailability();
|
const availability = useAvailability();
|
||||||
const downloadQueue = useDownloadQueueDialog();
|
const downloadQueue = useDownloadQueueDialog();
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
applyThemeMode(settings.themeMode);
|
applyThemeMode(settings.themeMode);
|
||||||
applyTheme(settings.theme);
|
applyTheme(settings.theme);
|
||||||
|
applyFont(settings.fontFamily);
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
@@ -88,9 +94,10 @@ function App() {
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
download.resetDownloadedTracks();
|
download.resetDownloadedTracks();
|
||||||
lyrics.resetLyricsState();
|
lyrics.resetLyricsState();
|
||||||
|
cover.resetCoverState();
|
||||||
availability.clearAvailability();
|
availability.clearAvailability();
|
||||||
setSortBy("default");
|
setSortBy("default");
|
||||||
setCurrentPage(1);
|
setCurrentListPage(1);
|
||||||
}, [metadata.metadata]);
|
}, [metadata.metadata]);
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
@@ -99,9 +106,12 @@ function App() {
|
|||||||
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
|
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// tag_name format: "v6.1" -> extract "6.1"
|
|
||||||
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
||||||
|
|
||||||
|
if (data.published_at) {
|
||||||
|
setReleaseDate(data.published_at);
|
||||||
|
}
|
||||||
|
|
||||||
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
||||||
setHasUpdate(true);
|
setHasUpdate(true);
|
||||||
}
|
}
|
||||||
@@ -166,7 +176,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to history when metadata is successfully fetched
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!metadata.metadata || !spotifyUrl) return;
|
if (!metadata.metadata || !spotifyUrl) return;
|
||||||
|
|
||||||
@@ -217,7 +226,7 @@ function App() {
|
|||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
setCurrentPage(1);
|
setCurrentListPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTrackSelection = (isrc: string) => {
|
const toggleTrackSelection = (isrc: string) => {
|
||||||
@@ -250,6 +259,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const renderMetadata = () => {
|
const renderMetadata = () => {
|
||||||
if (!metadata.metadata) return null;
|
if (!metadata.metadata) return null;
|
||||||
|
|
||||||
@@ -269,9 +279,11 @@ function App() {
|
|||||||
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
|
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
|
||||||
checkingAvailability={availability.checkingTrackId === track.spotify_id}
|
checkingAvailability={availability.checkingTrackId === track.spotify_id}
|
||||||
availability={availability.getAvailability(track.spotify_id || "")}
|
availability={availability.getAvailability(track.spotify_id || "")}
|
||||||
|
downloadingCover={cover.downloadingCover}
|
||||||
onDownload={download.handleDownloadTrack}
|
onDownload={download.handleDownloadTrack}
|
||||||
onDownloadLyrics={lyrics.handleDownloadLyrics}
|
onDownloadLyrics={lyrics.handleDownloadLyrics}
|
||||||
onCheckAvailability={availability.checkAvailability}
|
onCheckAvailability={availability.checkAvailability}
|
||||||
|
onDownloadCover={cover.handleDownloadCover}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -294,7 +306,7 @@ function App() {
|
|||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
downloadProgress={download.downloadProgress}
|
downloadProgress={download.downloadProgress}
|
||||||
currentDownloadInfo={download.currentDownloadInfo}
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
currentPage={currentPage}
|
currentPage={currentListPage}
|
||||||
itemsPerPage={ITEMS_PER_PAGE}
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
downloadedLyrics={lyrics.downloadedLyrics}
|
downloadedLyrics={lyrics.downloadedLyrics}
|
||||||
failedLyrics={lyrics.failedLyrics}
|
failedLyrics={lyrics.failedLyrics}
|
||||||
@@ -302,6 +314,11 @@ function App() {
|
|||||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||||
availabilityMap={availability.availabilityMap}
|
availabilityMap={availability.availabilityMap}
|
||||||
|
downloadedCovers={cover.downloadedCovers}
|
||||||
|
failedCovers={cover.failedCovers}
|
||||||
|
skippedCovers={cover.skippedCovers}
|
||||||
|
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||||
|
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
onToggleTrack={toggleTrackSelection}
|
onToggleTrack={toggleTrackSelection}
|
||||||
@@ -310,14 +327,18 @@ function App() {
|
|||||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
|
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
|
||||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position)
|
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position)
|
||||||
}
|
}
|
||||||
|
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
|
||||||
|
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, false, position, trackId)
|
||||||
|
}
|
||||||
onCheckAvailability={availability.checkAvailability}
|
onCheckAvailability={availability.checkAvailability}
|
||||||
|
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
|
||||||
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
|
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
|
||||||
onDownloadSelected={() =>
|
onDownloadSelected={() =>
|
||||||
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
|
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
|
||||||
}
|
}
|
||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentListPage}
|
||||||
onArtistClick={async (artist) => {
|
onArtistClick={async (artist) => {
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
@@ -351,7 +372,7 @@ function App() {
|
|||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
downloadProgress={download.downloadProgress}
|
downloadProgress={download.downloadProgress}
|
||||||
currentDownloadInfo={download.currentDownloadInfo}
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
currentPage={currentPage}
|
currentPage={currentListPage}
|
||||||
itemsPerPage={ITEMS_PER_PAGE}
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
downloadedLyrics={lyrics.downloadedLyrics}
|
downloadedLyrics={lyrics.downloadedLyrics}
|
||||||
failedLyrics={lyrics.failedLyrics}
|
failedLyrics={lyrics.failedLyrics}
|
||||||
@@ -359,6 +380,11 @@ function App() {
|
|||||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||||
availabilityMap={availability.availabilityMap}
|
availabilityMap={availability.availabilityMap}
|
||||||
|
downloadedCovers={cover.downloadedCovers}
|
||||||
|
failedCovers={cover.failedCovers}
|
||||||
|
skippedCovers={cover.skippedCovers}
|
||||||
|
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||||
|
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
onToggleTrack={toggleTrackSelection}
|
onToggleTrack={toggleTrackSelection}
|
||||||
@@ -367,7 +393,11 @@ function App() {
|
|||||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
|
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
|
||||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position)
|
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position)
|
||||||
}
|
}
|
||||||
|
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
|
||||||
|
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, false, position, trackId)
|
||||||
|
}
|
||||||
onCheckAvailability={availability.checkAvailability}
|
onCheckAvailability={availability.checkAvailability}
|
||||||
|
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
|
||||||
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
|
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
|
||||||
onDownloadSelected={() =>
|
onDownloadSelected={() =>
|
||||||
download.handleDownloadSelected(
|
download.handleDownloadSelected(
|
||||||
@@ -378,7 +408,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentListPage}
|
||||||
onAlbumClick={metadata.handleAlbumClick}
|
onAlbumClick={metadata.handleAlbumClick}
|
||||||
onArtistClick={async (artist) => {
|
onArtistClick={async (artist) => {
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
@@ -414,7 +444,7 @@ function App() {
|
|||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
downloadProgress={download.downloadProgress}
|
downloadProgress={download.downloadProgress}
|
||||||
currentDownloadInfo={download.currentDownloadInfo}
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
currentPage={currentPage}
|
currentPage={currentListPage}
|
||||||
itemsPerPage={ITEMS_PER_PAGE}
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
downloadedLyrics={lyrics.downloadedLyrics}
|
downloadedLyrics={lyrics.downloadedLyrics}
|
||||||
failedLyrics={lyrics.failedLyrics}
|
failedLyrics={lyrics.failedLyrics}
|
||||||
@@ -422,6 +452,11 @@ function App() {
|
|||||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||||
availabilityMap={availability.availabilityMap}
|
availabilityMap={availability.availabilityMap}
|
||||||
|
downloadedCovers={cover.downloadedCovers}
|
||||||
|
failedCovers={cover.failedCovers}
|
||||||
|
skippedCovers={cover.skippedCovers}
|
||||||
|
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||||
|
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
onToggleTrack={toggleTrackSelection}
|
onToggleTrack={toggleTrackSelection}
|
||||||
@@ -430,7 +465,11 @@ function App() {
|
|||||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) =>
|
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) =>
|
||||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position)
|
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position)
|
||||||
}
|
}
|
||||||
|
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, isArtistDiscography, position, trackId) =>
|
||||||
|
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, isArtistDiscography, position, trackId)
|
||||||
|
}
|
||||||
onCheckAvailability={availability.checkAvailability}
|
onCheckAvailability={availability.checkAvailability}
|
||||||
|
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name, true)}
|
||||||
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
|
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
|
||||||
onDownloadSelected={() =>
|
onDownloadSelected={() =>
|
||||||
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
|
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
|
||||||
@@ -444,7 +483,7 @@ function App() {
|
|||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentListPage}
|
||||||
onTrackClick={async (track) => {
|
onTrackClick={async (track) => {
|
||||||
if (track.external_urls) {
|
if (track.external_urls) {
|
||||||
setSpotifyUrl(track.external_urls);
|
setSpotifyUrl(track.external_urls);
|
||||||
@@ -458,144 +497,159 @@ function App() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case "settings":
|
||||||
|
return <SettingsPage />;
|
||||||
|
case "debug":
|
||||||
|
return <DebugLoggerPage />;
|
||||||
|
case "audio-analysis":
|
||||||
|
return <AudioAnalysisPage />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header
|
||||||
|
version={CURRENT_VERSION}
|
||||||
|
hasUpdate={hasUpdate}
|
||||||
|
releaseDate={releaseDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Timeout Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={metadata.showTimeoutDialog}
|
||||||
|
onOpenChange={metadata.setShowTimeoutDialog}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
|
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||||
|
with large discography.
|
||||||
|
</DialogDescription>
|
||||||
|
{metadata.pendingArtistName && (
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="timeout"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="600"
|
||||||
|
value={metadata.timeoutValue}
|
||||||
|
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||||
|
minutes).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={metadata.handleConfirmFetch}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Fetch
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Album Fetch Dialog */}
|
||||||
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
|
onClick={() => metadata.setShowAlbumDialog(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Do you want to fetch metadata for this album?
|
||||||
|
</DialogDescription>
|
||||||
|
{metadata.selectedAlbum && (
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={async () => {
|
||||||
|
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||||
|
if (albumUrl) {
|
||||||
|
setSpotifyUrl(albumUrl);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Fetch Album
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<SearchBar
|
||||||
|
url={spotifyUrl}
|
||||||
|
loading={metadata.loading}
|
||||||
|
onUrlChange={setSpotifyUrl}
|
||||||
|
onFetch={handleFetchMetadata}
|
||||||
|
history={fetchHistory}
|
||||||
|
onHistorySelect={handleHistorySelect}
|
||||||
|
onHistoryRemove={removeFromHistory}
|
||||||
|
hasResult={!!metadata.metadata}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{metadata.metadata && renderMetadata()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<div className="flex-1 p-4 md:p-8">
|
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
|
||||||
|
|
||||||
|
{/* Main content area with sidebar offset */}
|
||||||
|
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{currentPageView === "audio-analysis" ? (
|
{renderPage()}
|
||||||
<AudioAnalysisPage onBack={() => setCurrentPageView("main")} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Header
|
|
||||||
version={CURRENT_VERSION}
|
|
||||||
hasUpdate={hasUpdate}
|
|
||||||
onOpenAudioAnalysis={() => setCurrentPageView("audio-analysis")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Download Progress Toast - Bottom Left */}
|
|
||||||
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
|
||||||
|
|
||||||
{/* Download Queue Dialog */}
|
|
||||||
<DownloadQueue
|
|
||||||
isOpen={downloadQueue.isOpen}
|
|
||||||
onClose={downloadQueue.closeQueue}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Timeout Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={metadata.showTimeoutDialog}
|
|
||||||
onOpenChange={metadata.setShowTimeoutDialog}
|
|
||||||
>
|
|
||||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
|
||||||
<div className="absolute right-4 top-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
|
||||||
with large discography.
|
|
||||||
</DialogDescription>
|
|
||||||
{metadata.pendingArtistName && (
|
|
||||||
<div className="py-2">
|
|
||||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
|
||||||
<Input
|
|
||||||
id="timeout"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="600"
|
|
||||||
value={metadata.timeoutValue}
|
|
||||||
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
|
||||||
minutes).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={metadata.handleConfirmFetch}>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
Fetch
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Album Fetch Dialog */}
|
|
||||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
|
||||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
|
||||||
<div className="absolute right-4 top-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={() => metadata.setShowAlbumDialog(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Do you want to fetch metadata for this album?
|
|
||||||
</DialogDescription>
|
|
||||||
{metadata.selectedAlbum && (
|
|
||||||
<div className="py-2">
|
|
||||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={async () => {
|
|
||||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
|
||||||
if (albumUrl) {
|
|
||||||
setSpotifyUrl(albumUrl);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
Fetch Album
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<SearchBar
|
|
||||||
url={spotifyUrl}
|
|
||||||
loading={metadata.loading}
|
|
||||||
onUrlChange={setSpotifyUrl}
|
|
||||||
onFetch={handleFetchMetadata}
|
|
||||||
history={fetchHistory}
|
|
||||||
onHistorySelect={handleHistorySelect}
|
|
||||||
onHistoryRemove={removeFromHistory}
|
|
||||||
hasResult={!!metadata.metadata}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{metadata.metadata && renderMetadata()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Download Progress Toast - Bottom Left */}
|
||||||
|
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||||
|
|
||||||
|
{/* Download Queue Dialog */}
|
||||||
|
<DownloadQueue
|
||||||
|
isOpen={downloadQueue.isOpen}
|
||||||
|
onClose={downloadQueue.closeQueue}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen } from "lucide-react";
|
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
@@ -39,13 +40,21 @@ interface AlbumInfoProps {
|
|||||||
// Availability props
|
// Availability props
|
||||||
checkingAvailabilityTrack?: string | null;
|
checkingAvailabilityTrack?: string | null;
|
||||||
availabilityMap?: Map<string, TrackAvailability>;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
|
// Cover props
|
||||||
|
downloadedCovers?: Set<string>;
|
||||||
|
failedCovers?: Set<string>;
|
||||||
|
skippedCovers?: Set<string>;
|
||||||
|
downloadingCoverTrack?: string | null;
|
||||||
|
isBulkDownloadingCovers?: boolean;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||||
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||||
onCheckAvailability?: (spotifyId: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
|
onDownloadAllCovers?: () => void;
|
||||||
onDownloadAll: () => void;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
@@ -77,13 +86,20 @@ export function AlbumInfo({
|
|||||||
downloadingLyricsTrack,
|
downloadingLyricsTrack,
|
||||||
checkingAvailabilityTrack,
|
checkingAvailabilityTrack,
|
||||||
availabilityMap,
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
onDownloadLyrics,
|
onDownloadLyrics,
|
||||||
|
onDownloadCover,
|
||||||
onCheckAvailability,
|
onCheckAvailability,
|
||||||
|
onDownloadAllCovers,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
onStopDownload,
|
onStopDownload,
|
||||||
@@ -154,6 +170,22 @@ export function AlbumInfo({
|
|||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onDownloadAllCovers && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAllCovers}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isBulkDownloadingCovers}
|
||||||
|
>
|
||||||
|
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Covers</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{downloadedTracks.size > 0 && (
|
{downloadedTracks.size > 0 && (
|
||||||
<Button onClick={onOpenFolder} variant="outline">
|
<Button onClick={onOpenFolder} variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
@@ -204,6 +236,11 @@ export function AlbumInfo({
|
|||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
onDownloadLyrics={onDownloadLyrics}
|
onDownloadLyrics={onDownloadLyrics}
|
||||||
|
onDownloadCover={onDownloadCover}
|
||||||
|
downloadedCovers={downloadedCovers}
|
||||||
|
failedCovers={failedCovers}
|
||||||
|
skippedCovers={skippedCovers}
|
||||||
|
downloadingCoverTrack={downloadingCoverTrack}
|
||||||
onCheckAvailability={onCheckAvailability}
|
onCheckAvailability={onCheckAvailability}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
onTrackClick={onTrackClick}
|
onTrackClick={onTrackClick}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen } from "lucide-react";
|
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
@@ -44,13 +45,21 @@ interface ArtistInfoProps {
|
|||||||
// Availability props
|
// Availability props
|
||||||
checkingAvailabilityTrack?: string | null;
|
checkingAvailabilityTrack?: string | null;
|
||||||
availabilityMap?: Map<string, TrackAvailability>;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
|
// Cover props
|
||||||
|
downloadedCovers?: Set<string>;
|
||||||
|
failedCovers?: Set<string>;
|
||||||
|
skippedCovers?: Set<string>;
|
||||||
|
downloadingCoverTrack?: string | null;
|
||||||
|
isBulkDownloadingCovers?: boolean;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||||
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||||
onCheckAvailability?: (spotifyId: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
|
onDownloadAllCovers?: () => void;
|
||||||
onDownloadAll: () => void;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
@@ -84,13 +93,20 @@ export function ArtistInfo({
|
|||||||
downloadingLyricsTrack,
|
downloadingLyricsTrack,
|
||||||
checkingAvailabilityTrack,
|
checkingAvailabilityTrack,
|
||||||
availabilityMap,
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
onDownloadLyrics,
|
onDownloadLyrics,
|
||||||
|
onDownloadCover,
|
||||||
onCheckAvailability,
|
onCheckAvailability,
|
||||||
|
onDownloadAllCovers,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
onStopDownload,
|
onStopDownload,
|
||||||
@@ -196,6 +212,23 @@ export function ArtistInfo({
|
|||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onDownloadAllCovers && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAllCovers}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isBulkDownloadingCovers}
|
||||||
|
>
|
||||||
|
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Covers</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{downloadedTracks.size > 0 && (
|
{downloadedTracks.size > 0 && (
|
||||||
<Button onClick={onOpenFolder} size="sm" variant="outline">
|
<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
@@ -243,6 +276,11 @@ export function ArtistInfo({
|
|||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
onDownloadLyrics={onDownloadLyrics}
|
onDownloadLyrics={onDownloadLyrics}
|
||||||
|
onDownloadCover={onDownloadCover}
|
||||||
|
downloadedCovers={downloadedCovers}
|
||||||
|
failedCovers={failedCovers}
|
||||||
|
skippedCovers={skippedCovers}
|
||||||
|
downloadingCoverTrack={downloadingCoverTrack}
|
||||||
onCheckAvailability={onCheckAvailability}
|
onCheckAvailability={onCheckAvailability}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
onAlbumClick={onAlbumClick}
|
onAlbumClick={onAlbumClick}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
|||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
|
|
||||||
interface AudioAnalysisPageProps {
|
interface AudioAnalysisPageProps {
|
||||||
onBack: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
@@ -71,21 +71,18 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
{onBack && (
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
</Button>
|
<ArrowLeft className="h-5 w-5" />
|
||||||
<div>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
)}
|
||||||
<p className="text-sm text-muted-foreground">
|
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||||
Analyze FLAC files to verify true lossless quality
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Selection */}
|
{/* File Selection */}
|
||||||
{!result && !analyzing && (
|
{!result && !analyzing && (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center justify-center py-16 border-2 border-dashed rounded-lg transition-colors ${
|
className={`flex flex-col items-center justify-center py-24 border-2 border-dashed rounded-lg transition-colors ${
|
||||||
isDragging
|
isDragging
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/10"
|
||||||
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Trash2, Copy, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { logger, type LogEntry } from "@/lib/logger";
|
||||||
|
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
info: "text-blue-500",
|
||||||
|
success: "text-green-500",
|
||||||
|
warning: "text-yellow-500",
|
||||||
|
error: "text-red-500",
|
||||||
|
debug: "text-gray-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour12: false,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugLoggerPage() {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = logger.subscribe(() => {
|
||||||
|
setLogs(logger.getLogs());
|
||||||
|
});
|
||||||
|
setLogs(logger.getLogs());
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
logger.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const logText = logs
|
||||||
|
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(logText);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy logs:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs"
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground lowercase">no logs yet...</p>
|
||||||
|
) : (
|
||||||
|
logs.map((log, i) => (
|
||||||
|
<div key={i} className="flex gap-2 py-0.5">
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
[{formatTime(log.timestamp)}]
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
|
||||||
|
[{log.level}]
|
||||||
|
</span>
|
||||||
|
<span className="break-all">{log.message}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
|
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Settings } from "@/components/Settings";
|
|
||||||
import { Activity } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import { formatRelativeTime } from "@/lib/relative-time";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
version: string;
|
version: string;
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
onOpenAudioAnalysis: () => void;
|
releaseDate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps) {
|
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
@@ -33,15 +31,24 @@ export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps)
|
|||||||
SpotiFLAC
|
SpotiFLAC
|
||||||
</h1>
|
</h1>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Badge variant="default" asChild>
|
<Tooltip>
|
||||||
<button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<Badge variant="default" asChild>
|
||||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
|
<button
|
||||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
type="button"
|
||||||
>
|
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
|
||||||
v{version}
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
</button>
|
>
|
||||||
</Badge>
|
v{version}
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{hasUpdate && releaseDate && (
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{formatRelativeTime(releaseDate)}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
{hasUpdate && (
|
{hasUpdate && (
|
||||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
@@ -54,36 +61,6 @@ export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps)
|
|||||||
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 top-0 flex gap-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
|
||||||
aria-label="GitHub Issues"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<p>Report bug or request feature</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" onClick={onOpenAudioAnalysis}>
|
|
||||||
<Activity className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<p>Audio Quality Analyzer</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Settings />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen } from "lucide-react";
|
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
@@ -43,13 +44,21 @@ interface PlaylistInfoProps {
|
|||||||
// Availability props
|
// Availability props
|
||||||
checkingAvailabilityTrack?: string | null;
|
checkingAvailabilityTrack?: string | null;
|
||||||
availabilityMap?: Map<string, TrackAvailability>;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
|
// Cover props
|
||||||
|
downloadedCovers?: Set<string>;
|
||||||
|
failedCovers?: Set<string>;
|
||||||
|
skippedCovers?: Set<string>;
|
||||||
|
downloadingCoverTrack?: string | null;
|
||||||
|
isBulkDownloadingCovers?: boolean;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||||
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||||
onCheckAvailability?: (spotifyId: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
|
onDownloadAllCovers?: () => void;
|
||||||
onDownloadAll: () => void;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
@@ -82,13 +91,20 @@ export function PlaylistInfo({
|
|||||||
downloadingLyricsTrack,
|
downloadingLyricsTrack,
|
||||||
checkingAvailabilityTrack,
|
checkingAvailabilityTrack,
|
||||||
availabilityMap,
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
onDownloadLyrics,
|
onDownloadLyrics,
|
||||||
|
onDownloadCover,
|
||||||
onCheckAvailability,
|
onCheckAvailability,
|
||||||
|
onDownloadAllCovers,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
onStopDownload,
|
onStopDownload,
|
||||||
@@ -145,6 +161,22 @@ export function PlaylistInfo({
|
|||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onDownloadAllCovers && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAllCovers}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isBulkDownloadingCovers}
|
||||||
|
>
|
||||||
|
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Covers</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{downloadedTracks.size > 0 && (
|
{downloadedTracks.size > 0 && (
|
||||||
<Button onClick={onOpenFolder} variant="outline">
|
<Button onClick={onOpenFolder} variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
@@ -191,10 +223,15 @@ export function PlaylistInfo({
|
|||||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||||
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
||||||
availabilityMap={availabilityMap}
|
availabilityMap={availabilityMap}
|
||||||
|
downloadedCovers={downloadedCovers}
|
||||||
|
failedCovers={failedCovers}
|
||||||
|
skippedCovers={skippedCovers}
|
||||||
|
downloadingCoverTrack={downloadingCoverTrack}
|
||||||
onToggleTrack={onToggleTrack}
|
onToggleTrack={onToggleTrack}
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
onDownloadLyrics={onDownloadLyrics}
|
onDownloadLyrics={onDownloadLyrics}
|
||||||
|
onDownloadCover={onDownloadCover}
|
||||||
onCheckAvailability={onCheckAvailability}
|
onCheckAvailability={onCheckAvailability}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
onAlbumClick={onAlbumClick}
|
onAlbumClick={onAlbumClick}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X } from "lucide-react";
|
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
@@ -281,7 +282,7 @@ export function Settings() {
|
|||||||
|
|
||||||
{/* Theme Mode Selection */}
|
{/* Theme Mode Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="theme-mode">Theme</Label>
|
<Label htmlFor="theme-mode">Mode</Label>
|
||||||
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
||||||
<SelectTrigger id="theme-mode">
|
<SelectTrigger id="theme-mode">
|
||||||
<SelectValue placeholder="Select theme mode" />
|
<SelectValue placeholder="Select theme mode" />
|
||||||
@@ -294,9 +295,9 @@ export function Settings() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Theme Color Selection */}
|
{/* Accent Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="theme">Theme Color</Label>
|
<Label htmlFor="theme">Accent</Label>
|
||||||
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
||||||
<SelectTrigger id="theme">
|
<SelectTrigger id="theme">
|
||||||
<SelectValue placeholder="Select a theme" />
|
<SelectValue placeholder="Select a theme" />
|
||||||
@@ -400,6 +401,21 @@ export function Settings() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
{/* Sound Effects */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="sfx-enabled"
|
||||||
|
checked={tempSettings.sfxEnabled}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:justify-between">
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, type Settings as SettingsType, type FontFamily } from "@/lib/settings";
|
||||||
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
|
||||||
|
// Service Icons
|
||||||
|
const TidalIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
|
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||||
|
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeezerIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
|
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QobuzIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
|
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||||
|
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AmazonIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
|
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||||
|
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||||
|
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||||
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeMode(savedSettings.themeMode);
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleChange = () => {
|
||||||
|
if (savedSettings.themeMode === "auto") {
|
||||||
|
applyThemeMode("auto");
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
}, [savedSettings.themeMode, savedSettings.theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeMode(tempSettings.themeMode);
|
||||||
|
applyTheme(tempSettings.theme);
|
||||||
|
applyFont(tempSettings.fontFamily);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
}, 0);
|
||||||
|
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDefaults = async () => {
|
||||||
|
if (!savedSettings.downloadPath) {
|
||||||
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
|
setSavedSettings(settingsWithDefaults);
|
||||||
|
setTempSettings(settingsWithDefaults);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadDefaults();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveSettings(tempSettings);
|
||||||
|
setSavedSettings(tempSettings);
|
||||||
|
toast.success("Settings saved");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
const defaultSettings = await resetToDefaultSettings();
|
||||||
|
setTempSettings(defaultSettings);
|
||||||
|
setSavedSettings(defaultSettings);
|
||||||
|
applyThemeMode(defaultSettings.themeMode);
|
||||||
|
applyTheme(defaultSettings.theme);
|
||||||
|
applyFont(defaultSettings.fontFamily);
|
||||||
|
toast.success("Settings reset to default");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseFolder = async () => {
|
||||||
|
try {
|
||||||
|
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||||
|
if (selectedPath && selectedPath.trim() !== "") {
|
||||||
|
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error selecting folder:", error);
|
||||||
|
toast.error(`Error selecting folder: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Download Path */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="download-path">Download Path</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<InputWithContext
|
||||||
|
id="download-path"
|
||||||
|
value={tempSettings.downloadPath}
|
||||||
|
onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))}
|
||||||
|
placeholder="C:\Users\YourUsername\Music"
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="downloader">Source</Label>
|
||||||
|
<Select
|
||||||
|
value={tempSettings.downloader}
|
||||||
|
onValueChange={(value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="downloader">
|
||||||
|
<SelectValue placeholder="Select a source" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="tidal">
|
||||||
|
<span className="flex items-center"><TidalIcon />Tidal</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deezer">
|
||||||
|
<span className="flex items-center"><DeezerIcon />Deezer</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz">
|
||||||
|
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon">
|
||||||
|
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Mode */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="theme-mode">Mode</Label>
|
||||||
|
<Select
|
||||||
|
value={tempSettings.themeMode}
|
||||||
|
onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="theme-mode">
|
||||||
|
<SelectValue placeholder="Select theme mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="theme">Accent</Label>
|
||||||
|
<Select
|
||||||
|
value={tempSettings.theme}
|
||||||
|
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="theme">
|
||||||
|
<SelectValue placeholder="Select a theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<SelectItem key={theme.name} value={theme.name}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full border border-border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{theme.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="font">Font</Label>
|
||||||
|
<Select
|
||||||
|
value={tempSettings.fontFamily}
|
||||||
|
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="font">
|
||||||
|
<SelectValue placeholder="Select a font" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FONT_OPTIONS.map((font) => (
|
||||||
|
<SelectItem key={font.value} value={font.value}>
|
||||||
|
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filename Format */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Filename Format</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={tempSettings.filenameFormat}
|
||||||
|
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="title-artist" id="title-artist" />
|
||||||
|
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="artist-title" id="artist-title" />
|
||||||
|
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="title" id="title" />
|
||||||
|
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4" />
|
||||||
|
|
||||||
|
{/* Folder Settings */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-sm">Folder Settings</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="track-number"
|
||||||
|
checked={tempSettings.trackNumber}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs text-center">Adds track numbers to filenames</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="artist-subfolder"
|
||||||
|
checked={tempSettings.artistSubfolder}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs text-center">Playlist only</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="album-subfolder"
|
||||||
|
checked={tempSettings.albumSubfolder}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs text-center">Playlist & Discography</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4" />
|
||||||
|
|
||||||
|
{/* Sound Effects */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||||
|
<Switch
|
||||||
|
id="sfx-enabled"
|
||||||
|
checked={tempSettings.sfxEnabled}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 justify-between pt-4 border-t">
|
||||||
|
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} className="gap-1.5">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Home, Settings, Bug, Activity, LayoutGrid } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
currentPage: PageType;
|
||||||
|
onPageChange: (page: PageType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||||
|
const navItems = [
|
||||||
|
{ id: "main" as PageType, icon: Home, label: "Home" },
|
||||||
|
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
|
||||||
|
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
|
||||||
|
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||||
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Tooltip key={item.id} delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={currentPage === item.id ? "secondary" : "ghost"}
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() => onPageChange(item.id)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>{item.label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub - below debug */}
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Report Bug</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Other Projects at bottom */}
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() => openExternal("https://exyezed.cc/")}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Other Projects</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { X, Minus, Maximize } from "lucide-react";
|
||||||
import { X, Minus, Square } from "lucide-react";
|
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
|
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleMinimize = () => {
|
const handleMinimize = () => {
|
||||||
WindowMinimise();
|
WindowMinimise();
|
||||||
};
|
};
|
||||||
@@ -21,48 +18,36 @@ export function TitleBar() {
|
|||||||
<>
|
<>
|
||||||
{/* Draggable area */}
|
{/* Draggable area */}
|
||||||
<div
|
<div
|
||||||
className="fixed top-0 left-0 right-0 h-12 z-40"
|
className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm"
|
||||||
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
|
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
|
||||||
onDoubleClick={handleMaximize}
|
onDoubleClick={handleMaximize}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Window control buttons */}
|
{/* Window control buttons - Windows style, right side */}
|
||||||
<div className="fixed top-4 left-4 z-50 flex gap-2">
|
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
onMouseEnter={() => setHoveredButton("close")}
|
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
|
||||||
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
|
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
{hoveredButton === "close" && (
|
|
||||||
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleMinimize}
|
onClick={handleMinimize}
|
||||||
onMouseEnter={() => setHoveredButton("minimize")}
|
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
|
||||||
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
|
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
aria-label="Minimize"
|
aria-label="Minimize"
|
||||||
>
|
>
|
||||||
{hoveredButton === "minimize" && (
|
<Minus className="w-3.5 h-3.5" />
|
||||||
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleMaximize}
|
onClick={handleMaximize}
|
||||||
onMouseEnter={() => setHoveredButton("maximize")}
|
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
|
||||||
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
|
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
aria-label="Maximize"
|
aria-label="Maximize"
|
||||||
>
|
>
|
||||||
{hoveredButton === "maximize" && (
|
<Maximize className="w-3.5 h-3.5" />
|
||||||
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
|
</button>
|
||||||
)}
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
|
||||||
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe } from "lucide-react";
|
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -23,9 +24,11 @@ interface TrackInfoProps {
|
|||||||
skippedLyrics?: boolean;
|
skippedLyrics?: boolean;
|
||||||
checkingAvailability?: boolean;
|
checkingAvailability?: boolean;
|
||||||
availability?: TrackAvailability;
|
availability?: TrackAvailability;
|
||||||
|
downloadingCover?: boolean;
|
||||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
|
||||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||||
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,22 +45,52 @@ export function TrackInfo({
|
|||||||
skippedLyrics,
|
skippedLyrics,
|
||||||
checkingAvailability,
|
checkingAvailability,
|
||||||
availability,
|
availability,
|
||||||
|
downloadingCover,
|
||||||
onDownload,
|
onDownload,
|
||||||
onDownloadLyrics,
|
onDownloadLyrics,
|
||||||
onCheckAvailability,
|
onCheckAvailability,
|
||||||
|
onDownloadCover,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
}: TrackInfoProps) {
|
}: TrackInfoProps) {
|
||||||
|
const [isHoveringCover, setIsHoveringCover] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
<div className="shrink-0">
|
<div
|
||||||
|
className="shrink-0 relative"
|
||||||
|
onMouseEnter={() => setIsHoveringCover(true)}
|
||||||
|
onMouseLeave={() => setIsHoveringCover(false)}
|
||||||
|
>
|
||||||
{track.images && (
|
{track.images && (
|
||||||
<img
|
<>
|
||||||
src={track.images}
|
<img
|
||||||
alt={track.name}
|
src={track.images}
|
||||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
alt={track.name}
|
||||||
/>
|
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||||
|
/>
|
||||||
|
{isHoveringCover && onDownloadCover && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 rounded-md flex items-center justify-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name)}
|
||||||
|
disabled={downloadingCover}
|
||||||
|
>
|
||||||
|
{downloadingCover ? <Spinner /> : <ImageDown className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Cover</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-4 min-w-0">
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
@@ -99,6 +132,32 @@ export function TrackInfo({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{track.spotify_id && onDownloadLyrics && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
|
||||||
|
variant="outline"
|
||||||
|
disabled={downloadingLyricsTrack === track.spotify_id}
|
||||||
|
>
|
||||||
|
{downloadingLyricsTrack === track.spotify_id ? (
|
||||||
|
<Spinner />
|
||||||
|
) : skippedLyrics ? (
|
||||||
|
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||||
|
) : downloadedLyrics ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : failedLyrics ? (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Lyric</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{track.spotify_id && onCheckAvailability && (
|
{track.spotify_id && onCheckAvailability && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -130,32 +189,6 @@ export function TrackInfo({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{track.spotify_id && onDownloadLyrics && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
|
|
||||||
variant="outline"
|
|
||||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
|
||||||
>
|
|
||||||
{downloadingLyricsTrack === track.spotify_id ? (
|
|
||||||
<Spinner />
|
|
||||||
) : skippedLyrics ? (
|
|
||||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
|
||||||
) : downloadedLyrics ? (
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
||||||
) : failedLyrics ? (
|
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
|
||||||
) : (
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Download Lyric</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{isDownloaded && (
|
{isDownloaded && (
|
||||||
<Button onClick={onOpenFolder} variant="outline">
|
<Button onClick={onOpenFolder} variant="outline">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe } from "lucide-react";
|
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -42,11 +42,17 @@ interface TrackListProps {
|
|||||||
// Availability props
|
// Availability props
|
||||||
checkingAvailabilityTrack?: string | null;
|
checkingAvailabilityTrack?: string | null;
|
||||||
availabilityMap?: Map<string, TrackAvailability>;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
|
// Cover props
|
||||||
|
downloadedCovers?: Set<string>;
|
||||||
|
failedCovers?: Set<string>;
|
||||||
|
skippedCovers?: Set<string>;
|
||||||
|
downloadingCoverTrack?: string | null;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||||
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
|
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
|
||||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||||
@@ -75,11 +81,16 @@ export function TrackList({
|
|||||||
downloadingLyricsTrack,
|
downloadingLyricsTrack,
|
||||||
checkingAvailabilityTrack,
|
checkingAvailabilityTrack,
|
||||||
availabilityMap,
|
availabilityMap,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
downloadingCoverTrack,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
onDownloadLyrics,
|
onDownloadLyrics,
|
||||||
onCheckAvailability,
|
onCheckAvailability,
|
||||||
|
onDownloadCover,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onAlbumClick,
|
onAlbumClick,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
@@ -286,52 +297,39 @@ export function TrackList({
|
|||||||
<td className="p-4 align-middle text-center">
|
<td className="p-4 align-middle text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{track.isrc && (
|
{track.isrc && (
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5"
|
|
||||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
|
||||||
>
|
|
||||||
{downloadingTrack === track.isrc ? (
|
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Download
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{track.spotify_id && onCheckAvailability && (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
|
onClick={() =>
|
||||||
|
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
disabled={checkingAvailabilityTrack === track.spotify_id}
|
|
||||||
>
|
>
|
||||||
{checkingAvailabilityTrack === track.spotify_id ? (
|
{downloadingTrack === track.isrc ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : availabilityMap?.has(track.spotify_id) ? (
|
) : skippedTracks.has(track.isrc) ? (
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
<FileCheck className="h-4 w-4" />
|
||||||
|
) : downloadedTracks.has(track.isrc) ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : failedTracks.has(track.isrc) ? (
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Globe className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{availabilityMap?.has(track.spotify_id) ? (
|
{downloadingTrack === track.isrc ? (
|
||||||
<div className="flex items-center gap-2">
|
<p>Downloading...</p>
|
||||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
|
) : skippedTracks.has(track.isrc) ? (
|
||||||
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`} />
|
<p>Already exists</p>
|
||||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
|
) : downloadedTracks.has(track.isrc) ? (
|
||||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
|
<p>Downloaded</p>
|
||||||
</div>
|
) : failedTracks.has(track.isrc) ? (
|
||||||
|
<p>Failed</p>
|
||||||
) : (
|
) : (
|
||||||
<p>Check Availability</p>
|
<p>Download Track</p>
|
||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -365,6 +363,68 @@ export function TrackList({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{track.images && onDownloadCover && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||||
|
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
|
||||||
|
>
|
||||||
|
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||||
|
<Spinner />
|
||||||
|
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||||
|
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||||
|
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<ImageDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Cover</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{track.spotify_id && onCheckAvailability && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={checkingAvailabilityTrack === track.spotify_id}
|
||||||
|
>
|
||||||
|
{checkingAvailabilityTrack === track.spotify_id ? (
|
||||||
|
<Spinner />
|
||||||
|
) : availabilityMap?.has(track.spotify_id) ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{availabilityMap?.has(track.spotify_id) ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
|
||||||
|
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`} />
|
||||||
|
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
|
||||||
|
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p>Check Availability</p>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "var(--border)",
|
||||||
"--border-radius": "var(--radius)",
|
"--border-radius": "var(--radius)",
|
||||||
|
left: "calc(56px + 1rem)",
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { downloadCover } from "@/lib/api";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
export function useCover() {
|
||||||
|
const [downloadingCover, setDownloadingCover] = useState(false);
|
||||||
|
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
|
||||||
|
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
|
||||||
|
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
|
||||||
|
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
|
||||||
|
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
|
||||||
|
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
|
||||||
|
const stopBulkDownloadRef = useRef(false);
|
||||||
|
|
||||||
|
const handleDownloadCover = async (
|
||||||
|
coverUrl: string,
|
||||||
|
trackName: string,
|
||||||
|
artistName: string,
|
||||||
|
albumName?: string,
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean,
|
||||||
|
position?: number,
|
||||||
|
trackId?: string
|
||||||
|
) => {
|
||||||
|
if (!coverUrl) {
|
||||||
|
toast.error("No cover URL found for this track");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = trackId || `${trackName}-${artistName}`;
|
||||||
|
logger.info(`downloading cover: ${trackName} - ${artistName}`);
|
||||||
|
const settings = getSettings();
|
||||||
|
setDownloadingCover(true);
|
||||||
|
setDownloadingCoverTrack(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
|
// Build output path similar to audio download
|
||||||
|
if (playlistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||||
|
|
||||||
|
if (isArtistDiscography) {
|
||||||
|
if (settings.albumSubfolder && albumName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (settings.artistSubfolder && artistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
|
||||||
|
}
|
||||||
|
if (settings.albumSubfolder && albumName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: coverUrl,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: position || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
toast.info("Cover file already exists");
|
||||||
|
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||||
|
} else {
|
||||||
|
toast.success("Cover downloaded successfully");
|
||||||
|
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
setFailedCovers((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Failed to download cover");
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
} finally {
|
||||||
|
setDownloadingCover(false);
|
||||||
|
setDownloadingCoverTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAllCovers = async (
|
||||||
|
tracks: TrackMetadata[],
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean
|
||||||
|
) => {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
toast.error("No tracks to download covers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
setIsBulkDownloadingCovers(true);
|
||||||
|
setCoverDownloadProgress(0);
|
||||||
|
stopBulkDownloadRef.current = false;
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
let success = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
if (stopBulkDownloadRef.current) {
|
||||||
|
toast.info("Cover download stopped");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = tracks[i];
|
||||||
|
if (!track.images) {
|
||||||
|
completed++;
|
||||||
|
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = track.spotify_id || `${track.name}-${track.artists}`;
|
||||||
|
setDownloadingCoverTrack(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
|
if (playlistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||||
|
|
||||||
|
if (isArtistDiscography) {
|
||||||
|
if (settings.albumSubfolder && track.album_name) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (settings.artistSubfolder && track.artists) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(track.artists, os));
|
||||||
|
}
|
||||||
|
if (settings.albumSubfolder && track.album_name) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: track.images,
|
||||||
|
track_name: track.name,
|
||||||
|
artist_name: track.artists,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: i + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
skipped++;
|
||||||
|
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||||
|
} else {
|
||||||
|
success++;
|
||||||
|
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
completed++;
|
||||||
|
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadingCoverTrack(null);
|
||||||
|
setIsBulkDownloadingCovers(false);
|
||||||
|
setCoverDownloadProgress(0);
|
||||||
|
|
||||||
|
if (!stopBulkDownloadRef.current) {
|
||||||
|
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopCoverDownload = () => {
|
||||||
|
stopBulkDownloadRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCoverState = () => {
|
||||||
|
setDownloadedCovers(new Set());
|
||||||
|
setFailedCovers(new Set());
|
||||||
|
setSkippedCovers(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
downloadingCover,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
|
coverDownloadProgress,
|
||||||
|
handleDownloadCover,
|
||||||
|
handleDownloadAllCovers,
|
||||||
|
handleStopCoverDownload,
|
||||||
|
resetCoverState,
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-1
@@ -5,8 +5,10 @@ import type {
|
|||||||
HealthResponse,
|
HealthResponse,
|
||||||
LyricsDownloadRequest,
|
LyricsDownloadRequest,
|
||||||
LyricsDownloadResponse,
|
LyricsDownloadResponse,
|
||||||
|
CoverDownloadRequest,
|
||||||
|
CoverDownloadResponse,
|
||||||
} from "@/types/api";
|
} from "@/types/api";
|
||||||
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics } from "../../wailsjs/go/main/App";
|
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
|
||||||
import { main } from "../../wailsjs/go/models";
|
import { main } from "../../wailsjs/go/models";
|
||||||
|
|
||||||
export async function fetchSpotifyMetadata(
|
export async function fetchSpotifyMetadata(
|
||||||
@@ -48,3 +50,10 @@ export async function downloadLyrics(
|
|||||||
const req = new main.LyricsDownloadRequest(request);
|
const req = new main.LyricsDownloadRequest(request);
|
||||||
return await DownloadLyrics(req);
|
return await DownloadLyrics(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadCover(
|
||||||
|
request: CoverDownloadRequest
|
||||||
|
): Promise<CoverDownloadResponse> {
|
||||||
|
const req = new main.CoverDownloadRequest(request);
|
||||||
|
return await DownloadCover(req);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Format a date to relative time string with max 2 units
|
||||||
|
* e.g., "23 hours 32 minutes ago", "1 day 14 hours ago"
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: Date | string | number): string {
|
||||||
|
const now = new Date();
|
||||||
|
const target = new Date(date);
|
||||||
|
const diffMs = now.getTime() - target.getTime();
|
||||||
|
|
||||||
|
if (diffMs < 0) return "just now";
|
||||||
|
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (years > 0) {
|
||||||
|
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
|
||||||
|
const remainingMonths = Math.floor((days % 365) / 30);
|
||||||
|
if (remainingMonths > 0) {
|
||||||
|
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
|
||||||
|
}
|
||||||
|
} else if (months > 0) {
|
||||||
|
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
|
||||||
|
const remainingDays = days % 30;
|
||||||
|
if (remainingDays > 0) {
|
||||||
|
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||||
|
}
|
||||||
|
} else if (weeks > 0) {
|
||||||
|
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
|
||||||
|
const remainingDays = days % 7;
|
||||||
|
if (remainingDays > 0) {
|
||||||
|
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||||
|
}
|
||||||
|
} else if (days > 0) {
|
||||||
|
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
|
||||||
|
const remainingHours = hours % 24;
|
||||||
|
if (remainingHours > 0) {
|
||||||
|
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
|
||||||
|
}
|
||||||
|
} else if (hours > 0) {
|
||||||
|
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
if (remainingMinutes > 0) {
|
||||||
|
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
|
||||||
|
}
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
||||||
|
} else {
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Released " + parts.slice(0, 2).join(" ") + " ago";
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { GetDefaults } from "../../wailsjs/go/main/App";
|
import { GetDefaults } from "../../wailsjs/go/main/App";
|
||||||
|
|
||||||
|
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk";
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
|
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
|
||||||
theme: string;
|
theme: string;
|
||||||
themeMode: "auto" | "light" | "dark";
|
themeMode: "auto" | "light" | "dark";
|
||||||
|
fontFamily: FontFamily;
|
||||||
filenameFormat: "title-artist" | "artist-title" | "title";
|
filenameFormat: "title-artist" | "artist-title" | "title";
|
||||||
artistSubfolder: boolean;
|
artistSubfolder: boolean;
|
||||||
albumSubfolder: boolean;
|
albumSubfolder: boolean;
|
||||||
trackNumber: boolean;
|
trackNumber: boolean;
|
||||||
|
sfxEnabled: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS"
|
operatingSystem: "Windows" | "linux/MacOS"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,13 +30,34 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
downloader: "auto",
|
downloader: "auto",
|
||||||
theme: "yellow",
|
theme: "yellow",
|
||||||
themeMode: "auto",
|
themeMode: "auto",
|
||||||
|
fontFamily: "google-sans",
|
||||||
filenameFormat: "title-artist",
|
filenameFormat: "title-artist",
|
||||||
artistSubfolder: false,
|
artistSubfolder: false,
|
||||||
albumSubfolder: false,
|
albumSubfolder: false,
|
||||||
trackNumber: false,
|
trackNumber: false,
|
||||||
|
sfxEnabled: true,
|
||||||
operatingSystem: detectOS()
|
operatingSystem: detectOS()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
|
||||||
|
{ 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: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||||
|
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||||
|
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||||
|
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||||
|
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||||
|
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function applyFont(fontFamily: FontFamily): void {
|
||||||
|
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||||
|
if (font) {
|
||||||
|
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
||||||
|
document.body.style.fontFamily = font.fontFamily;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDefaultPath(): Promise<string> {
|
async function fetchDefaultPath(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = await GetDefaults();
|
const data = await GetDefaults();
|
||||||
|
|||||||
@@ -6,38 +6,42 @@ import {
|
|||||||
playInfoSound,
|
playInfoSound,
|
||||||
} from "./audio";
|
} from "./audio";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import { getSettings } from "./settings";
|
||||||
|
|
||||||
const toastStyle = {
|
const toastStyle = {
|
||||||
className: "font-mono lowercase",
|
className: "font-mono lowercase",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to check if SFX is enabled
|
||||||
|
const isSfxEnabled = () => getSettings().sfxEnabled;
|
||||||
|
|
||||||
// Wrapper functions for toast with sound effects
|
// Wrapper functions for toast with sound effects
|
||||||
export const toastWithSound = {
|
export const toastWithSound = {
|
||||||
success: (message: string, data?: any) => {
|
success: (message: string, data?: any) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.success(msg);
|
logger.success(msg);
|
||||||
playSuccessSound();
|
if (isSfxEnabled()) playSuccessSound();
|
||||||
return toast.success(msg, { ...toastStyle, ...data });
|
return toast.success(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
error: (message: string, data?: any) => {
|
error: (message: string, data?: any) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.error(msg);
|
logger.error(msg);
|
||||||
playErrorSound();
|
if (isSfxEnabled()) playErrorSound();
|
||||||
return toast.error(msg, { ...toastStyle, ...data });
|
return toast.error(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
warning: (message: string, data?: any) => {
|
warning: (message: string, data?: any) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.warning(msg);
|
logger.warning(msg);
|
||||||
playWarningSound();
|
if (isSfxEnabled()) playWarningSound();
|
||||||
return toast.warning(msg, { ...toastStyle, ...data });
|
return toast.warning(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
info: (message: string, data?: any) => {
|
info: (message: string, data?: any) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.info(msg);
|
logger.info(msg);
|
||||||
playInfoSound();
|
if (isSfxEnabled()) playInfoSound();
|
||||||
return toast.info(msg, { ...toastStyle, ...data });
|
return toast.info(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@ export const toastWithSound = {
|
|||||||
message: (message: string, data?: any) => {
|
message: (message: string, data?: any) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.info(msg);
|
logger.info(msg);
|
||||||
playInfoSound();
|
if (isSfxEnabled()) playInfoSound();
|
||||||
return toast(msg, { ...toastStyle, ...data });
|
return toast(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ import { createRoot } from "react-dom/client";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { DebugLogger } from "@/components/DebugLogger";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="bottom-left" duration={1000} />
|
<Toaster position="bottom-left" duration={1000} />
|
||||||
<div className="fixed bottom-2 left-2 z-50">
|
|
||||||
<DebugLogger />
|
|
||||||
</div>
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -200,4 +200,22 @@ export interface TrackAvailability {
|
|||||||
qobuz_url?: string;
|
qobuz_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CoverDownloadRequest {
|
||||||
|
cover_url: string;
|
||||||
|
track_name: string;
|
||||||
|
artist_name: string;
|
||||||
|
output_dir?: string;
|
||||||
|
filename_format?: string;
|
||||||
|
track_number?: boolean;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverDownloadResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
file?: string;
|
||||||
|
error?: string;
|
||||||
|
already_exists?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "6.5"
|
"version": "6.6"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "6.5"
|
"productVersion": "6.6"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user