This commit is contained in:
afkarxyz
2026-01-15 11:03:27 +07:00
parent d9cf5a5361
commit b160d3c790
34 changed files with 2368 additions and 1293 deletions
+108
View File
@@ -6,8 +6,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
goRuntime "runtime"
"spotiflac/backend" "spotiflac/backend"
"strings" "strings"
"time" "time"
@@ -31,6 +33,14 @@ func NewApp() *App {
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
if err := backend.InitHistoryDB("SpotiFLAC"); err != nil {
fmt.Printf("Failed to init history DB: %v\n", err)
}
}
func (a *App) shutdown(ctx context.Context) {
backend.CloseHistoryDB()
} }
type SpotifyMetadataRequest struct { type SpotifyMetadataRequest struct {
@@ -471,6 +481,40 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
backend.CompleteDownloadItem(itemID, filename, 0) backend.CompleteDownloadItem(itemID, filename, 0)
} }
go func(fPath, track, artist, album, sID, cover, format string) {
quality := "Unknown"
durationStr := "--:--"
meta, err := backend.GetTrackMetadata(fPath)
if err == nil && meta != nil {
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
d := int(meta.Duration)
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
} else {
}
item := backend.HistoryItem{
SpotifyID: sID,
Title: track,
Artists: artist,
Album: album,
DurationStr: durationStr,
CoverURL: cover,
Quality: quality,
Format: format,
Path: fPath,
}
if item.Format == "" || item.Format == "LOSSLESS" {
ext := filepath.Ext(fPath)
if len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:])
}
}
backend.AddHistoryItem(item, "SpotiFLAC")
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat)
} }
return DownloadResponse{ return DownloadResponse{
@@ -544,6 +588,14 @@ func (a *App) Quit() {
panic("quit") panic("quit")
} }
func (a *App) GetDownloadHistory() ([]backend.HistoryItem, error) {
return backend.GetHistoryItems("SpotiFLAC")
}
func (a *App) ClearDownloadHistory() error {
return backend.ClearHistory("SpotiFLAC")
}
func (a *App) AnalyzeTrack(filePath string) (string, error) { func (a *App) AnalyzeTrack(filePath string) (string, error) {
if filePath == "" { if filePath == "" {
return "", fmt.Errorf("file path is required") return "", fmt.Errorf("file path is required")
@@ -1127,3 +1179,59 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
func (a *App) CheckFFmpegInstalled() (bool, error) { func (a *App) CheckFFmpegInstalled() (bool, error) {
return backend.IsFFmpegInstalled() return backend.IsFFmpegInstalled()
} }
func (a *App) GetOSInfo() (string, error) {
osType := goRuntime.GOOS
arch := goRuntime.GOARCH
switch osType {
case "windows":
out, err := exec.Command("wmic", "os", "get", "Caption,Version", "/value").Output()
if err != nil {
outVer, errVer := exec.Command("cmd", "/c", "ver").Output()
if errVer != nil {
return fmt.Sprintf("Windows %s", arch), nil
}
return strings.TrimSpace(string(outVer)), nil
}
lines := strings.Split(string(out), "\n")
var caption, version string
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Caption=") {
caption = strings.TrimPrefix(line, "Caption=")
} else if strings.HasPrefix(line, "Version=") {
version = strings.TrimPrefix(line, "Version=")
}
}
if caption != "" && version != "" {
return fmt.Sprintf("%s (%s, %s)", caption, version, arch), nil
}
return strings.TrimSpace(string(out)), nil
case "darwin":
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return fmt.Sprintf("macOS %s", arch), nil
}
version := strings.TrimSpace(string(out))
return fmt.Sprintf("macOS %s (%s)", version, arch), nil
case "linux":
out, err := exec.Command("cat", "/etc/os-release").Output()
if err == nil {
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "PRETTY_NAME=") {
name := strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
return fmt.Sprintf("%s (%s)", name, arch), nil
}
}
}
return fmt.Sprintf("Linux %s", arch), nil
default:
return fmt.Sprintf("%s %s", osType, arch), nil
}
}
+43
View File
@@ -162,3 +162,46 @@ func GetFileSize(filepath string) (int64, error) {
} }
return info.Size(), nil return info.Size(), nil
} }
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
}
}
}
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
+149
View File
@@ -0,0 +1,149 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
type HistoryItem struct {
ID string `json:"id"`
SpotifyID string `json:"spotify_id"`
Title string `json:"title"`
Artists string `json:"artists"`
Album string `json:"album"`
DurationStr string `json:"duration_str"`
CoverURL string `json:"cover_url"`
Quality string `json:"quality"`
Format string `json:"format"`
Path string `json:"path"`
Timestamp int64 `json:"timestamp"`
}
var historyDB *bolt.DB
const (
historyBucket = "DownloadHistory"
maxHistory = 10000
)
func InitHistoryDB(appName string) error {
appDir, err := GetFFmpegDir()
if err != nil {
return err
}
if _, err := os.Stat(appDir); os.IsNotExist(err) {
os.MkdirAll(appDir, 0755)
}
dbPath := filepath.Join(appDir, "history.db")
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return err
}
err = db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
return err
})
if err != nil {
db.Close()
return err
}
historyDB = db
return nil
}
func CloseHistoryDB() {
if historyDB != nil {
historyDB.Close()
}
}
func AddHistoryItem(item HistoryItem, appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
id, _ := b.NextSequence()
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
item.Timestamp = time.Now().Unix()
buf, err := json.Marshal(item)
if err != nil {
return err
}
if b.Stats().KeyN >= maxHistory {
c := b.Cursor()
toDelete := maxHistory / 20
if toDelete < 1 {
toDelete = 1
}
count := 0
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
if err := b.Delete(k); err != nil {
return err
}
count++
}
}
return b.Put([]byte(item.ID), buf)
})
}
func GetHistoryItems(appName string) ([]HistoryItem, error) {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return nil, err
}
}
var items []HistoryItem
err := historyDB.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(historyBucket))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var item HistoryItem
if err := json.Unmarshal(v, &item); err == nil {
items = append(items, item)
}
}
return nil
})
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp > items[j].Timestamp
})
return items, err
}
func ClearHistory(appName string) error {
if historyDB == nil {
if err := InitHistoryDB(appName); err != nil {
return err
}
}
return historyDB.Update(func(tx *bolt.Tx) error {
return tx.DeleteBucket([]byte(historyBucket))
})
}
+1
View File
@@ -20,6 +20,7 @@
"@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.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
+1 -1
View File
@@ -1 +1 @@
68754ba75ba7fe058dd9ebf6593e2759 42597f825aff483763c8cb00c83bfa74
+32
View File
@@ -32,6 +32,9 @@ importers:
'@radix-ui/react-switch': '@radix-ui/react-switch':
specifier: ^1.2.6 specifier: ^1.2.6
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-toggle': '@radix-ui/react-toggle':
specifier: ^1.1.10 specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -896,6 +899,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-toggle-group@1.1.11': '@radix-ui/react-toggle-group@1.1.11':
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
peerDependencies: peerDependencies:
@@ -2767,6 +2783,22 @@ snapshots:
'@types/react': 19.2.8 '@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8) '@types/react-dom': 19.2.3(@types/react@19.2.8)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
+128 -19
View File
@@ -7,7 +7,8 @@ import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App"; import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App";
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { TitleBar } from "@/components/TitleBar"; import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar"; import { Sidebar, type PageType } from "@/components/Sidebar";
@@ -24,6 +25,8 @@ import { AudioConverterPage } from "@/components/AudioConverterPage";
import { FileManagerPage } from "@/components/FileManagerPage"; import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage"; import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import { AboutPage } from "@/components/AboutPage";
import { HistoryPage } from "@/components/HistoryPage";
import type { HistoryItem } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory";
import { useDownload } from "@/hooks/useDownload"; import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata"; import { useMetadata } from "@/hooks/useMetadata";
@@ -31,6 +34,7 @@ import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover"; 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";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
const HISTORY_KEY = "spotiflac_fetch_history"; const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5; const MAX_HISTORY = 5;
function App() { function App() {
@@ -50,13 +54,18 @@ function App() {
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "7.0.5"; const CURRENT_VERSION = "7.0.6";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
const lyrics = useLyrics(); const lyrics = useLyrics();
const cover = useCover(); const cover = useCover();
const availability = useAvailability(); const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog(); const downloadQueue = useDownloadQueueDialog();
const downloadProgress = useDownloadProgress();
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null);
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
useLayoutEffect(() => { useLayoutEffect(() => {
const savedSettings = getSettings(); const savedSettings = getSettings();
if (savedSettings) { if (savedSettings) {
@@ -65,7 +74,6 @@ function App() {
applyFont(savedSettings.fontFamily); applyFont(savedSettings.fontFamily);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
const initSettings = async () => { const initSettings = async () => {
const settings = await loadSettings(); const settings = await loadSettings();
@@ -78,6 +86,17 @@ function App() {
} }
}; };
initSettings(); initSettings();
const checkFFmpeg = async () => {
try {
const installed = await CheckFFmpegInstalled();
setIsFFmpegInstalled(installed);
}
catch (err) {
console.error("Failed to check FFmpeg:", err);
setIsFFmpegInstalled(false);
}
};
checkFFmpeg();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => { const handleChange = () => {
const currentSettings = getSettings(); const currentSettings = getSettings();
@@ -138,6 +157,44 @@ function App() {
console.error("Failed to load history:", err); console.error("Failed to load history:", err);
} }
}; };
const handleInstallFFmpeg = async () => {
setIsInstallingFFmpeg(true);
setFfmpegInstallProgress(0);
setFfmpegInstallStatus("starting");
try {
EventsOn("ffmpeg:progress", (progress: number) => {
setFfmpegInstallProgress(progress);
if (progress >= 100) {
setFfmpegInstallStatus("extracting");
}
else {
setFfmpegInstallStatus("downloading");
}
});
EventsOn("ffmpeg:status", (status: string) => {
setFfmpegInstallStatus(status);
});
const response = await DownloadFFmpeg();
EventsOff("ffmpeg:progress");
EventsOff("ffmpeg:status");
if (response.success) {
toast.success("FFmpeg installed successfully!");
setIsFFmpegInstalled(true);
}
else {
toast.error(`Failed to install FFmpeg: ${response.error}`);
}
}
catch (error) {
console.error("Error installing FFmpeg:", error);
toast.error(`Error during FFmpeg installation: ${error}`);
}
finally {
setIsInstallingFFmpeg(false);
setFfmpegInstallProgress(0);
setFfmpegInstallStatus("");
}
};
const saveHistory = (history: HistoryItem[]) => { const saveHistory = (history: HistoryItem[]) => {
try { try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
@@ -262,7 +319,7 @@ function App() {
return null; return null;
if ("track" in metadata.metadata) { if ("track" in metadata.metadata) {
const { track } = metadata.metadata; const { track } = metadata.metadata;
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} />); return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>);
} }
if ("album_info" in metadata.metadata) { if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata; const { album_info, track_list } = metadata.metadata;
@@ -276,7 +333,7 @@ function App() {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}} />); }}/>);
} }
if ("playlist_info" in metadata.metadata) { if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata; const { playlist_info, track_list } = metadata.metadata;
@@ -290,7 +347,7 @@ function App() {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}} />); }}/>);
} }
if ("artist_info" in metadata.metadata) { if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata; const { artist_info, album_list, track_list } = metadata.metadata;
@@ -304,7 +361,7 @@ function App() {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls); await metadata.handleFetchMetadata(track.external_urls);
} }
}} />); }}/>);
} }
return null; return null;
}; };
@@ -337,9 +394,13 @@ function App() {
const renderPage = () => { const renderPage = () => {
switch (currentPage) { switch (currentPage) {
case "settings": case "settings":
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn} />; return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
case "debug": case "debug":
return <DebugLoggerPage />; return <DebugLoggerPage />;
case "about":
return <AboutPage version={CURRENT_VERSION}/>;
case "history":
return <HistoryPage />;
case "audio-analysis": case "audio-analysis":
return <AudioAnalysisPage />; return <AudioAnalysisPage />;
case "audio-converter": case "audio-converter":
@@ -348,14 +409,14 @@ function App() {
return <FileManagerPage />; return <FileManagerPage />;
default: default:
return (<> return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate} /> <Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}> <Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden"> <DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4"> <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)}> <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" /> <X className="h-4 w-4"/>
</Button> </Button>
</div> </div>
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle> <DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
@@ -369,7 +430,7 @@ function App() {
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label> <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))} /> <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"> <p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes). minutes).
@@ -381,7 +442,7 @@ function App() {
Cancel Cancel
</Button> </Button>
<Button onClick={metadata.handleConfirmFetch}> <Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4" /> <Search className="h-4 w-4"/>
Fetch Fetch
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -393,7 +454,7 @@ function App() {
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden"> <DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4"> <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)}> <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" /> <X className="h-4 w-4"/>
</Button> </Button>
</div> </div>
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle> <DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
@@ -413,7 +474,7 @@ function App() {
setSpotifyUrl(albumUrl); setSpotifyUrl(albumUrl);
} }
}}> }}>
<Search className="h-4 w-4" /> <Search className="h-4 w-4"/>
Fetch Album Fetch Album
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -426,7 +487,7 @@ function App() {
if (updatedUrl) { if (updatedUrl) {
setSpotifyUrl(updatedUrl); setSpotifyUrl(updatedUrl);
} }
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} /> }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/>
{!isSearchMode && metadata.metadata && renderMetadata()} {!isSearchMode && metadata.metadata && renderMetadata()}
</>); </>);
@@ -435,7 +496,7 @@ function App() {
return (<TooltipProvider> return (<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-background flex flex-col">
<TitleBar /> <TitleBar />
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} /> <Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
<div className="flex-1 ml-14 mt-10 p-4 md:p-8"> <div className="flex-1 ml-14 mt-10 p-4 md:p-8">
@@ -445,14 +506,14 @@ function App() {
</div> </div>
<DownloadProgressToast onClick={downloadQueue.openQueue} /> <DownloadProgressToast onClick={downloadQueue.openQueue}/>
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue} /> <DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon"> {showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
<ArrowUp className="h-5 w-5" /> <ArrowUp className="h-5 w-5"/>
</Button>)} </Button>)}
@@ -474,6 +535,54 @@ function App() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
<DialogContent className="max-w-[360px] [&>button]:hidden p-6 gap-5">
<DialogHeader className="space-y-2">
<DialogTitle className="text-lg font-bold tracking-tight">
FFmpeg Required
</DialogTitle>
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
FFmpeg is essential for SpotiFLAC to function properly.
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
</DialogDescription>
</DialogHeader>
{isInstallingFFmpeg && (<div className="space-y-4">
{ffmpegInstallStatus === "extracting" ? (<div className="flex flex-col items-center justify-center py-2 animate-in fade-in duration-500">
<div className="flex items-center gap-3">
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
<span className="text-sm font-bold tracking-tight">Extracting...</span>
</div>
<span className="text-[10px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">Finalizing setup</span>
</div>) : (<div className="space-y-3">
<div className="flex justify-between text-[11px] font-bold">
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground uppercase tracking-wider">Downloading...</span>
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-primary font-mono tabular-nums">
{downloadProgress.mb_downloaded.toFixed(1)}MB
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(1)}MB/s`}
</span>)}
</div>
<span className="text-xl font-bold tracking-tighter text-primary">{ffmpegInstallProgress}%</span>
</div>
<div className="h-1.5 w-full bg-secondary/30 rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_rgba(var(--primary),0.3)]" style={{ width: `${ffmpegInstallProgress}%` }}/>
</div>
</div>)}
</div>)}
<DialogFooter className="flex-row gap-3 pt-2">
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
Exit
</Button>)}
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={handleInstallFFmpeg} disabled={isInstallingFFmpeg}>
{isInstallingFFmpeg ? "Installing..." : "Install now"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</TooltipProvider>); </TooltipProvider>);
} }
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1082.89 1083">
<defs>
<style>
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #fff;
}
</style>
</defs>
<rect class="cls-1" width="1082.89" height="1083" rx="121.76" ry="121.76"/>
<path class="cls-2" d="m340.59,516.87v-231.24h412.6c4.22,59.25,1.05,108.22-9.52,146.9-10.59,38.7-26.3,69.53-47.15,92.49-20.86,22.98-44.59,40.06-71.19,51.23-26.61,11.19-54.11,18.59-82.52,22.22-28.42,3.63-55.31,5.44-80.71,5.44,13.29,21.17,30.52,37.04,51.69,47.61,21.15,10.58,43.37,17.68,66.65,21.31,23.27,3.63,44.89,5.75,64.84,6.35h84.33v93.4h-88.87c-35.07-.61-70.43-4.53-106.1-11.79-35.68-7.25-68.01-20.09-97.03-38.54-29.02-18.43-52.45-44.28-70.28-77.53-17.84-33.24-26.75-75.86-26.75-127.86Zm312.85-138.74h-213.1v136.93c75.56-.6,129.97-10.57,163.23-29.92,33.24-19.34,49.87-55,49.87-107Z"/>
</svg>

After

Width:  |  Height:  |  Size: 962 B

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #2dc261;
fill-rule: evenodd;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
<g>
<g id="Layer_1">
<g id="SVGRepo_iconCarrier">
<g id="Page-1" sketch:type="MSPage">
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup">
<path id="arrow-right-circle" class="cls-1" d="M350.1,268.7l-81.5,81.5c-5.6,5.6-14.7,5.6-20.4,0-5.6-5.6-5.6-14.8,0-20.5l59.4-59.3h-152.5c-8,0-14.4-6.5-14.4-14.4s6.4-14.4,14.4-14.4h152.5l-59.4-59.3c-5.6-5.6-5.6-14.7,0-20.5,5.6-5.6,14.7-5.6,20.4,0l81.5,81.5c3.5,3.5,4.5,8.2,3.7,12.7.8,4.5-.3,9.2-3.7,12.7h0ZM256,25.6c-127.3,0-230.4,103.1-230.4,230.4s103.2,230.4,230.4,230.4,230.4-103.1,230.4-230.4S383.3,25.6,256,25.6h0Z" sketch:type="MSShapeGroup"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: #733e0a;
}
.cls-2 {
fill: #fdc700;
}
.cls-3 {
fill: #1ed760;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
<g>
<g id="Layer_1">
<g>
<g id="_1818452274576">
<g id="SVGRepo_iconCarrier">
<path class="cls-3" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
</g>
</g>
<path class="cls-2" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1Z"/>
<g>
<path class="cls-1" d="M113.6,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v35h17.5v-35c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.7,4.4-2.2,5.9-1.5,1.5-3.5,2.2-5.9,2.2s-4.4-.8-5.9-2.3c-1.5-1.5-2.3-3.5-2.3-5.8v-39.5h-17.5v39.5c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M175.9,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
<path class="cls-1" d="M200.4,434c-2,0-3.7-.7-5.2-2.2-1.5-1.4-2.2-3.2-2.2-5.3s.7-3.8,2.2-5.2c1.4-1.4,3.2-2.2,5.2-2.2h19.5c2,0,3.8.7,5.2,2.2s2.2,3.2,2.2,5.2-.7,3.8-2.2,5.3-3.2,2.2-5.2,2.2h-19.5Z"/>
<path class="cls-1" d="M250.3,477.2c-1.4,1.4-3.4,2.1-6,2.1s-4.6-.7-6-2.1c-1.4-1.4-2.1-3.4-2.1-6v-88.4c0-2.6.7-4.6,2.1-6,1.4-1.4,3.4-2.1,6-2.1h16c8.4,0,14.5,2,18.4,5.9,3.9,3.9,5.8,9.9,5.8,18v6.4c0,10.7-3.6,17.6-10.7,20.5v.3c3.9,1.2,6.7,3.6,8.4,7.2s2.5,8.5,2.5,14.7v16.1c0,2.5.2,4.5.6,6.1.4,1.5.6,2.8.6,4.1,0,3.6-2.6,5.4-7.7,5.4s-5.9-1-7.5-2.9c-1.6-2-2.4-5.2-2.4-9.8v-19.8c0-4.8-.8-8.1-2.3-10-1.5-1.9-4.2-2.8-7.9-2.8h-5.6v37.2c0,2.6-.7,4.6-2.1,6ZM252.4,419.1h5.9c3.2,0,5.7-.8,7.3-2.5s2.5-4.5,2.5-8.5v-8c0-3.7-.7-6.4-2-8.1s-3.4-2.6-6.3-2.6h-7.5v29.7Z"/>
<path class="cls-1" d="M304,478.4c-2.4,0-4.3-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.9v-87.5c0-2.4.8-4.3,2.3-5.9s3.5-2.3,5.9-2.3h29.8c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-21.7v27.4h15.9c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-15.9v31.9h21.7c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-29.8Z"/>
<path class="cls-1" d="M371.2,479.9c-7.9,0-13.8-2.2-17.9-6.6-4.1-4.4-6.1-10.6-6.1-18.6s.7-4.3,2-5.7c1.4-1.4,3.3-2.1,5.7-2.1s4.2.7,5.6,2c1.4,1.3,2.1,3.4,2.1,6.2,0,6.8,2.8,10.1,8.5,10.1s8.5-3.5,8.5-10.4-1-8.1-3-11.4c-2-3.3-5.6-7.3-11-12-6.8-5.9-11.5-11.3-14.1-16.1-2.7-4.8-4-10.2-4-16.2s2.1-14.6,6.3-19c4.2-4.5,10.2-6.7,18.1-6.7s13.5,2.2,17.6,6.6c4.1,4.4,6.2,10.1,6.2,17s-2.6,7.8-7.7,7.8-4.4-.7-5.7-2.2-2-3.3-2-5.6-.7-4.9-2.1-6.4c-1.4-1.5-3.4-2.3-6-2.3-5.5,0-8.2,3.3-8.2,9.9s1,7.3,3.1,10.5c2.1,3.2,5.7,7.2,11,11.9,6.8,6,11.4,11.4,14,16.3,2.6,4.9,3.9,10.5,3.9,17s-2.1,15-6.3,19.5c-4.2,4.6-10.3,6.8-18.3,6.8Z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#00bc7d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-music-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M9 17v-13h10v8" /><path d="M9 8h10" /><path d="M19 16v6" /><path d="M22 19l-3 3l-3 -3" /></svg>

After

Width:  |  Height:  |  Size: 448 B

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
id="Layer_1" width="512px" height="512px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;"
xml:space="preserve">
<g fill="#1da0f1">
<polygon
points="12.153992,10.729553 8.088684,5.041199 5.92041,5.041199 10.956299,12.087097 11.59021,12.97345 15.900635,19.009583 18.068909,19.009583 12.785217,11.615906 " />
<path
d="M21.15979,1H2.84021C1.823853,1,1,1.823853,1,2.84021v18.31958C1,22.176147,1.823853,23,2.84021,23h18.31958 C22.176147,23,23,22.176147,23,21.15979V2.84021C23,1.823853,22.176147,1,21.15979,1z M15.235352,20l-4.362549-6.213013 L5.411438,20H4l6.246887-7.104675L4,4h4.764648l4.130127,5.881958L18.06958,4h1.411377l-5.95697,6.775635L20,20H15.235352z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 865 B

+270
View File
@@ -0,0 +1,270 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
import { GetOSInfo } from "../../wailsjs/go/main/App";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Bug, Lightbulb, ExternalLink } from "lucide-react";
import ExyezedIcon from "@/assets/icons/exyezed.svg";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
interface AboutPageProps {
version: string;
}
export function AboutPage({ version }: AboutPageProps) {
const [os, setOs] = useState("Unknown");
const [location, setLocation] = useState("Unknown");
const [reportType, setReportType] = useState("bug");
const [problem, setProblem] = useState("");
const [bugType, setBugType] = useState<string>("Track");
const [spotifyUrl, setSpotifyUrl] = useState("");
const [bugContext, setBugContext] = useState("");
const [featureDesc, setFeatureDesc] = useState("");
const [useCase, setUseCase] = useState("");
const [featureContext, setFeatureContext] = useState("");
useEffect(() => {
const fetchOS = async () => {
try {
const info = await GetOSInfo();
setOs(info);
}
catch (err) {
const userAgent = window.navigator.userAgent;
if (userAgent.indexOf("Win") !== -1)
setOs("Windows");
else if (userAgent.indexOf("Mac") !== -1)
setOs("macOS");
else if (userAgent.indexOf("Linux") !== -1)
setOs("Linux");
}
};
fetchOS();
const fetchLocation = async () => {
try {
const response = await fetch('https://ipapi.co/json/');
if (response.ok) {
const data = await response.json();
const city = data.city || '';
const region = data.region || '';
const country = data.country_name || '';
const parts = [city, region, country].filter(Boolean);
setLocation(parts.join(', ') || 'Unknown');
}
else {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
setLocation(timezone);
}
}
catch (err) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
setLocation(timezone);
}
};
fetchLocation();
}, []);
const faqs = [
{
q: "Is this software free?",
a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection."
},
{
q: "Can using this software get my Spotify account suspended or banned?",
a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication."
},
{
q: "Where does the audio come from?",
a: "The audio is fetched using third-party APIs."
},
{
q: "Why does metadata fetching sometimes fail?",
a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit."
},
{
q: "Why does Windows Defender or antivirus flag or delete the file?",
a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source."
}
];
const sanitizeForURL = (text: string): string => {
return text.replace(/[()]/g, "").replace(/,/g, " -");
};
const handleSubmit = () => {
let title = "";
let body = "";
if (reportType === "bug") {
title = `[Bug Report] ${problem ? problem.substring(0, 50) + (problem.length > 50 ? "..." : "") : "Issue"}`;
body = `### [Bug Report]
#### Problem
> ${problem || "Type here"}
#### Type
${bugType || "Track / Album / Playlist / Artist"}
#### Spotify URL
> ${spotifyUrl || "Type here"}
#### Additional Context
> ${bugContext || "Type here or send screenshot/recording"}
#### Version
SpotiFLAC v${version}
#### OS
${sanitizeForURL(os || "Unknown")}
#### Location
${location || "Unknown"}
`;
}
else {
title = `[Feature Request] ${featureDesc ? featureDesc.substring(0, 50) + (featureDesc.length > 50 ? "..." : "") : "Request"}`;
body = `### [Feature Request]
#### Description
> ${featureDesc || "Type here"}
#### Use Case
> ${useCase || "Type here"}
#### Additional Context
> ${featureContext || "Type here or send screenshot/recording"}
`;
}
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
openExternal(url);
};
return (<div className="animate-in slide-in-from-bottom-12 fade-in duration-500 ease-out space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">About</h2>
</div>
<Tabs defaultValue="report" className="w-full">
<TabsList className="grid w-full grid-cols-3 cursor-pointer">
<TabsTrigger value="report" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Report Issue</TabsTrigger>
<TabsTrigger value="faq" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">FAQ</TabsTrigger>
<TabsTrigger value="projects" className="cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none">Other Projects</TabsTrigger>
</TabsList>
<TabsContent value="report" className="mt-4">
<Card>
<CardContent className="space-y-4 pt-4">
<Tabs value={reportType} onValueChange={setReportType} className="w-full">
<TabsList className="w-full grid grid-cols-2 cursor-pointer pb-2">
<TabsTrigger value="bug" className="flex items-center gap-2 cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"><Bug className="h-4 w-4" /> Bug Report</TabsTrigger>
<TabsTrigger value="feature" className="flex items-center gap-2 cursor-pointer transition-colors hover:text-primary data-[state=active]:text-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"><Lightbulb className="h-4 w-4" /> Feature Request</TabsTrigger>
</TabsList>
<div className="mt-4">
{reportType === "bug" ? (<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2 flex flex-col">
<Label>Problem</Label>
<Textarea className="flex-1 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)} />
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label>Type</Label>
<ToggleGroup type="single" value={bugType} onValueChange={(val) => {
if (val)
setBugType(val);
}} className="justify-start w-full cursor-pointer">
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">
Track
</ToggleGroupItem>
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">
Album
</ToggleGroupItem>
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">
Playlist
</ToggleGroupItem>
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">
Artist
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-2">
<Label>Spotify URL</Label>
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)} />
</div>
<div className="space-y-2 h-full">
<Label>Additional Context</Label>
<Textarea className="h-[125px] resize-none" placeholder="Any other details? Screenshots or recordings are very helpful (please upload directly to GitHub)." value={bugContext} onChange={e => setBugContext(e.target.value)} />
</div>
</div>
</div>) : (<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2 flex flex-col">
<Label>Description</Label>
<Textarea className="flex-1 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)} />
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label>Use Case</Label>
<Textarea className="h-[100px] resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Additional Context</Label>
<Textarea className="h-[135px] resize-none" placeholder="Any other details? Screenshots/recordings or examples..." value={featureContext} onChange={e => setFeatureContext(e.target.value)} />
</div>
</div>
</div>)}
</div>
</Tabs>
<div className="flex justify-center pt-2">
<Button className="w-[200px] cursor-pointer" onClick={handleSubmit}>
<ExternalLink className="h-4 w-4" /> Create Issue on GitHub
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="faq" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Frequently Asked Questions</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
</div>))}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="projects" className="mt-4 space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://exyezed.cc/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={ExyezedIcon} className="h-5 w-5 invert dark:invert-0" alt="exyezed" /> exyezed.cc</CardTitle>
<CardDescription>Browser Extensions & Scripts</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://spotubedl.com/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL" /> SpotubeDL</CardTitle>
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader" /> SpotiDownloader</CardTitle>
<CardDescription>Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.</CardDescription>
</CardHeader>
</Card>
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
<CardHeader>
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader" /> Twitter/X Batch Downloader</CardTitle>
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
</CardHeader>
</Card>
</div>
</TabsContent>
</Tabs>
</div>);
}
+3 -86
View File
@@ -1,14 +1,12 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, Download, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react"; import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { IsFFmpegInstalled, DownloadFFmpeg, ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App"; import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
interface AudioFile { interface AudioFile {
path: string; path: string;
name: string; name: string;
@@ -38,9 +36,6 @@ const M4A_CODEC_OPTIONS = [
]; ];
const STORAGE_KEY = "spotiflac_audio_converter_state"; const STORAGE_KEY = "spotiflac_audio_converter_state";
export function AudioConverterPage() { export function AudioConverterPage() {
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
const downloadProgress = useDownloadProgress();
const [files, setFiles] = useState<AudioFile[]>(() => { const [files, setFiles] = useState<AudioFile[]>(() => {
try { try {
const saved = sessionStorage.getItem(STORAGE_KEY); const saved = sessionStorage.getItem(STORAGE_KEY);
@@ -114,9 +109,6 @@ export function AudioConverterPage() {
console.error("Failed to save state:", err); console.error("Failed to save state:", err);
} }
}, []); }, []);
useEffect(() => {
checkFfmpegInstallation();
}, []);
useEffect(() => { useEffect(() => {
saveState({ files, outputFormat, bitrate, m4aCodec }); saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, m4aCodec, saveState]); }, [files, outputFormat, bitrate, m4aCodec, saveState]);
@@ -147,41 +139,6 @@ export function AudioConverterPage() {
window.removeEventListener("focus", checkFullscreen); window.removeEventListener("focus", checkFullscreen);
}; };
}, []); }, []);
const checkFfmpegInstallation = async () => {
try {
const installed = await IsFFmpegInstalled();
setFfmpegInstalled(installed);
}
catch (err) {
console.error("Failed to check ffmpeg:", err);
setFfmpegInstalled(false);
}
};
const handleInstallFfmpeg = async () => {
setInstallingFfmpeg(true);
try {
const result = await DownloadFFmpeg();
if (result.success) {
toast.success("FFmpeg Installed", {
description: "FFmpeg has been installed successfully",
});
setFfmpegInstalled(true);
}
else {
toast.error("Installation Failed", {
description: result.error || "Failed to install FFmpeg",
});
}
}
catch (err) {
toast.error("Installation Failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
}
finally {
setInstallingFfmpeg(false);
}
};
const handleSelectFiles = async () => { const handleSelectFiles = async () => {
try { try {
const selectedFiles = await SelectAudioFiles(); const selectedFiles = await SelectAudioFiles();
@@ -250,15 +207,13 @@ export function AudioConverterPage() {
addFiles(paths); addFiles(paths);
}, [addFiles]); }, [addFiles]);
useEffect(() => { useEffect(() => {
if (ffmpegInstalled === true) {
OnFileDrop((x, y, paths) => { OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths); handleFileDrop(x, y, paths);
}, true); }, true);
return () => { return () => {
OnFileDropOff(); OnFileDropOff();
}; };
} }, [handleFileDrop]);
}, [handleFileDrop, ffmpegInstalled]);
const removeFile = (path: string) => { const removeFile = (path: string) => {
setFiles((prev) => prev.filter((f) => f.path !== path)); setFiles((prev) => prev.filter((f) => f.path !== path));
}; };
@@ -336,44 +291,6 @@ export function AudioConverterPage() {
}; };
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
const successCount = files.filter((f) => f.status === "success").length; const successCount = files.filter((f) => f.status === "success").length;
if (ffmpegInstalled === false) {
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Audio Converter</h1>
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} border-muted-foreground/30`}>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Download className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
FFmpeg is required to convert audio files
</p>
<Button onClick={handleInstallFfmpeg} disabled={installingFfmpeg} size="lg">
{installingFfmpeg ? (<>
<Spinner className="h-5 w-5"/>
Installing FFmpeg...
</>) : (<>
<Download className="h-5 w-5"/>
Install FFmpeg
</>)}
</Button>
{installingFfmpeg && downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<div className="w-full max-w-md mt-6 space-y-2 px-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Downloading FFmpeg</span>
<span className="font-mono tabular-nums">
{downloadProgress.mb_downloaded.toFixed(2)} MB
{downloadProgress.speed_mbps > 0 && (<span className="text-muted-foreground ml-2">
@ {downloadProgress.speed_mbps.toFixed(2)} MB/s
</span>)}
</span>
</div>
<Progress value={Math.min(100, (downloadProgress.mb_downloaded / 200) * 100)} className="h-2"/>
</div>)}
</div>
</div>);
}
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}> return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
+1 -55
View File
@@ -17,12 +17,6 @@ const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (windo
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format); const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format); const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path); const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
const IsFFprobeInstalled = (): Promise<boolean> => (window as any)['go']['main']['App']['IsFFprobeInstalled']();
const DownloadFFmpeg = (): Promise<{
success: boolean;
message: string;
error?: string;
}> => (window as any)['go']['main']['App']['DownloadFFmpeg']();
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path); const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName); const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path); const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
@@ -118,8 +112,6 @@ export function FileManagerPage() {
const [metadataFile, setMetadataFile] = useState<string>(""); const [metadataFile, setMetadataFile] = useState<string>("");
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null); const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
const [loadingMetadata, setLoadingMetadata] = useState(false); const [loadingMetadata, setLoadingMetadata] = useState(false);
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
const [installingFFprobe, setInstallingFFprobe] = useState(false);
const [showLyricsPreview, setShowLyricsPreview] = useState(false); const [showLyricsPreview, setShowLyricsPreview] = useState(false);
const [lyricsContent, setLyricsContent] = useState(""); const [lyricsContent, setLyricsContent] = useState("");
const [lyricsFile, setLyricsFile] = useState(""); const [lyricsFile, setLyricsFile] = useState("");
@@ -279,14 +271,6 @@ export function FileManagerPage() {
toast.error("No files selected"); toast.error("No files selected");
return; return;
} }
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
if (hasM4A) {
const installed = await IsFFprobeInstalled();
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
try { try {
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat); const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
setPreviewData(result); setPreviewData(result);
@@ -299,13 +283,6 @@ export function FileManagerPage() {
}; };
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => { const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (filePath.toLowerCase().endsWith(".m4a")) {
const installed = await IsFFprobeInstalled();
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
setMetadataFile(filePath); setMetadataFile(filePath);
setLoadingMetadata(true); setLoadingMetadata(true);
try { try {
@@ -321,24 +298,6 @@ export function FileManagerPage() {
setLoadingMetadata(false); setLoadingMetadata(false);
} }
}; };
const handleInstallFFprobe = async () => {
setInstallingFFprobe(true);
try {
const result = await DownloadFFmpeg();
if (result.success) {
toast.success("FFprobe installed successfully");
setShowFFprobeDialog(false);
}
else
toast.error("Failed to install FFprobe", { description: result.error || result.message });
}
catch (err) {
toast.error("Failed to install FFprobe", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setInstallingFFprobe(false);
}
};
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => { const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setLyricsFile(filePath); setLyricsFile(filePath);
@@ -707,20 +666,7 @@ export function FileManagerPage() {
</Dialog> </Dialog>
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>FFprobe Required</DialogTitle>
<DialogDescription>Reading M4A metadata requires FFprobe. Would you like to download and install it now?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>Cancel</Button>
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
{installingFFprobe ? <><Spinner className="h-4 w-4"/>Installing...</> : "Install FFprobe"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}> <Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
+281
View File
@@ -0,0 +1,281 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, ExternalLink, Search, ArrowUpDown, History } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import { GetDownloadHistory, ClearDownloadHistory } from "../../wailsjs/go/main/App";
import { openExternal } from "@/lib/utils";
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
interface HistoryItem {
id: string;
spotify_id: string;
title: string;
artists: string;
album: string;
duration_str: string;
cover_url: string;
quality: string;
format: string;
path: string;
timestamp: number;
}
export function HistoryPage() {
const [history, setHistory] = useState<HistoryItem[]>([]);
const [filteredHistory, setFilteredHistory] = useState<HistoryItem[]>([]);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState("default");
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 50;
const fetchHistory = async () => {
try {
const items = await GetDownloadHistory();
setHistory(items || []);
}
catch (err) {
console.error("Failed to fetch history:", err);
}
};
useEffect(() => {
fetchHistory();
const interval = setInterval(fetchHistory, 5000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
let result = [...history];
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(item => item.title.toLowerCase().includes(query) ||
item.artists.toLowerCase().includes(query) ||
item.album.toLowerCase().includes(query));
}
const parseDuration = (str: string) => {
const parts = str.split(':').map(Number);
if (parts.length === 2)
return parts[0] * 60 + parts[1];
if (parts.length === 3)
return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
result.sort((a, b) => {
switch (sortBy) {
case "default":
case "date_desc":
return b.timestamp - a.timestamp;
case "date_asc":
return a.timestamp - b.timestamp;
case "title_asc":
return a.title.localeCompare(b.title);
case "title_desc":
return b.title.localeCompare(a.title);
case "artist_asc":
return a.artists.localeCompare(b.artists);
case "artist_desc":
return b.artists.localeCompare(a.artists);
case "duration_asc":
return parseDuration(a.duration_str) - parseDuration(b.duration_str);
case "duration_desc":
return parseDuration(b.duration_str) - parseDuration(a.duration_str);
default:
return 0;
}
});
setFilteredHistory(result);
setCurrentPage(1);
}, [history, searchQuery, sortBy]);
const totalPages = Math.ceil(filteredHistory.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const paginatedHistory = filteredHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
if (total <= 10) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | 'ellipsis')[] = [];
pages.push(1);
if (current <= 7) {
for (let i = 2; i <= 10; i++)
pages.push(i);
pages.push('ellipsis');
pages.push(total);
}
else if (current >= total - 7) {
pages.push('ellipsis');
for (let i = total - 9; i <= total; i++)
pages.push(i);
}
else {
pages.push('ellipsis');
pages.push(current - 1);
pages.push(current);
pages.push(current + 1);
pages.push('ellipsis');
pages.push(total);
}
return pages;
};
const handleClearHistory = async () => {
await ClearDownloadHistory();
fetchHistory();
setShowClearConfirm(false);
};
return (<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">Download History</h2>
{history.length > 0 && (<Badge variant="secondary" className="font-mono">
{history.length.toLocaleString('en-US')}
</Badge>)}
</div>
<Button variant="outline" size="sm" onClick={() => setShowClearConfirm(true)} disabled={history.length === 0} className="cursor-pointer gap-2">
<Trash2 className="h-4 w-4"/> Clear
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
<Input placeholder="Search history..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px] h-9">
<ArrowUpDown className="mr-2 h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="date_desc">Date (Newest)</SelectItem>
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-md border overflow-hidden">
{paginatedHistory.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
<History className="h-10 w-10 opacity-40"/>
</div>
<div className="space-y-1">
<p className="font-medium text-foreground/80">No download history</p>
<p className="text-sm">Your downloaded tracks will appear here.</p>
</div>
</div>) : (<table className="w-full table-fixed">
<thead>
<tr className="border-b bg-muted/50">
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-40 text-xs uppercase text-nowrap">Downloaded At</th>
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-16 text-xs uppercase text-nowrap">Link</th>
</tr>
</thead>
<tbody>
{paginatedHistory.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
{startIndex + index + 1}
</td>
<td className="p-3 align-middle min-w-0">
<div className="flex items-center gap-3 min-w-0">
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-sm truncate">{item.title}</span>
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
</div>
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-[10px] font-bold text-foreground">{item.format}</span>
{item.quality && <span className="text-[9px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
</td>
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
{item.duration_str}
</td>
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
{formatDate(item.timestamp)}
</td>
<td className="p-3 align-middle text-center">
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
<ExternalLink className="h-4 w-4"/>
</Button>
</td>
</tr>))}
</tbody>
</table>)}
</div>
{totalPages > 1 && (<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>) : (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>)))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
</PaginationItem>
</PaginationContent>
</Pagination>)}
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Clear Download History?</DialogTitle>
<DialogDescription>
This will remove all entries from your download history. This action cannot be undone.
Note: The actual downloaded files will NOT be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearConfirm(false)} className="cursor-pointer">Cancel</Button>
<Button variant="destructive" onClick={handleClearHistory} className="cursor-pointer">
Clear History
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+134 -97
View File
@@ -5,24 +5,28 @@ import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; import { FolderOpen, Save, RotateCcw, Info, ArrowRight } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Progress } from "@/components/ui/progress";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } 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";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const TidalIcon = ({ className }: {
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground"> className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "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="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> <path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>); </svg>);
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground"> const QobuzIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "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="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> <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>); </svg>);
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground"> const AmazonIcon = ({ className }: {
className?: string;
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "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="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> <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>); </svg>);
@@ -35,10 +39,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings); const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showFFmpegWarning, setShowFFmpegWarning] = useState(false); const [showHiResWarning, setShowHiResWarning] = useState(false);
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); const [pendingQuality, setPendingQuality] = useState<{
const [installProgress, setInstallProgress] = useState(0); type: 'tidal' | 'qobuz' | 'auto';
const downloadProgress = useDownloadProgress(); value: string;
} | null>(null);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => { const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings(); const freshSavedSettings = getSettings();
@@ -117,48 +122,43 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
}; };
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
if (value === "HI_RES_LOSSLESS") { if (value === "HI_RES_LOSSLESS") {
try { setPendingQuality({ type: 'tidal', value });
const { CheckFFmpegInstalled } = await import("../../wailsjs/go/main/App"); setShowHiResWarning(true);
const isInstalled = await CheckFFmpegInstalled();
if (!isInstalled) {
setShowFFmpegWarning(true);
return; return;
} }
}
catch (error) {
console.error("Error checking FFmpeg:", error);
}
}
setTempSettings((prev) => ({ ...prev, tidalQuality: value })); setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
}; };
const handleInstallFFmpeg = async () => { const handleQobuzQualityChange = (value: "6" | "7") => {
setIsInstallingFFmpeg(true); if (value === "7") {
setInstallProgress(0); setPendingQuality({ type: 'qobuz', value });
try { setShowHiResWarning(true);
const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App");
const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime");
EventsOn("ffmpeg:progress", (progress: number) => {
setInstallProgress(progress);
});
const response = await DownloadFFmpeg();
EventsOff("ffmpeg:progress");
if (response.success) {
toast.success("FFmpeg installed successfully!");
setShowFFmpegWarning(false);
setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" }));
} }
else { else {
toast.error(`Failed to install FFmpeg: ${response.error}`); setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
}
};
const handleAutoQualityChange = async (value: "16" | "24") => {
if (value === "24") {
setPendingQuality({ type: 'auto', value });
setShowHiResWarning(true);
return;
}
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
};
const handleConfirmHiRes = () => {
if (pendingQuality) {
if (pendingQuality.type === 'tidal') {
setTempSettings((prev) => ({ ...prev, tidalQuality: pendingQuality.value as "LOSSLESS" | "HI_RES_LOSSLESS" }));
}
else if (pendingQuality.type === 'qobuz') {
setTempSettings((prev) => ({ ...prev, qobuzQuality: pendingQuality.value as "6" | "7" }));
}
else if (pendingQuality.type === 'auto') {
setTempSettings((prev) => ({ ...prev, autoQuality: pendingQuality.value as "16" | "24" }));
} }
} }
catch (error) { setShowHiResWarning(false);
console.error("Error installing FFmpeg:", error); setPendingQuality(null);
toast.error(`Error during FFmpeg installation: ${error}`);
}
finally {
setIsInstallingFFmpeg(false);
setInstallProgress(0);
}
}; };
return (<div className="space-y-6"> return (<div className="space-y-6">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
@@ -170,9 +170,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label> <Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2"> <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" /> <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"> <Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4"/>
Browse Browse
</Button> </Button>
</div> </div>
@@ -183,7 +183,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label htmlFor="theme-mode">Mode</Label> <Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}> <Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode"> <SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" /> <SelectValue placeholder="Select theme mode"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
@@ -198,14 +198,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label htmlFor="theme">Accent</Label> <Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}> <Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme"> <SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" /> <SelectValue placeholder="Select a theme"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}> {themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{ <span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}} /> }}/>
{theme.label} {theme.label}
</span> </span>
</SelectItem>))} </SelectItem>))}
@@ -218,7 +218,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label htmlFor="font">Font</Label> <Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}> <Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font"> <SelectTrigger id="font">
<SelectValue placeholder="Select a font" /> <SelectValue placeholder="Select a font"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}> {FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
@@ -231,7 +231,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label> <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 }))} /> <Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
</div> </div>
</div> </div>
@@ -243,7 +243,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}> <Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
<SelectTrigger id="downloader" className="h-9 w-fit"> <SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source" /> <SelectValue placeholder="Select a source"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto</SelectItem> <SelectItem value="auto">Auto</SelectItem>
@@ -259,31 +259,85 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({ ...prev, autoOrder: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="tidal-amazon">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="qobuz-tidal">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="amazon-tidal">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="amazon-qobuz">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="tidal-amazon-qobuz">
<span className="flex items-center gap-1.5"><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="qobuz-tidal-amazon">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="qobuz-amazon-tidal">
<span className="flex items-center gap-1.5"><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="amazon-tidal-qobuz">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/></span>
</SelectItem>
<SelectItem value="amazon-qobuz-tidal">
<span className="flex items-center gap-1.5"><AmazonIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><QobuzIcon className="fill-white"/><ArrowRight className="h-3 w-3 text-muted-foreground"/><TidalIcon className="fill-white"/></span>
</SelectItem>
</SelectContent>
</Select>
<Select value={tempSettings.autoQuality || "16"} onValueChange={handleAutoQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
<SelectItem value="24">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>
</>)}
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}> {tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem> <SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem> <SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
</SelectContent> </SelectContent>
</Select>)} </Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}> {tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem> <SelectItem value="6">16-bit/44.1kHz</SelectItem>
<SelectItem value="7">FLAC 24-bit (Studio Quality)</SelectItem> <SelectItem value="7">24-bit/48kHz</SelectItem>
</SelectContent> </SelectContent>
</Select>)} </Select>)}
{tempSettings.downloader === "amazon" && ( {tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default"> 16-bit/44.1kHz / 24-bit/48kHz
16-bit/44.1kHz or 24-bit/48kHz+ </div>)}
</div>
)}
</div> </div>
</div> </div>
@@ -291,15 +345,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label> <Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))} /> <Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label> <Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))} /> <Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
</div> </div>
</div> </div>
<div className="border-t" /> <div className="border-t"/>
<div className="space-y-2"> <div className="space-y-2">
@@ -307,7 +361,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label className="text-sm">Folder Structure</Label> <Label className="text-sm">Folder Structure</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
@@ -330,14 +384,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1" />)} {tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
</div> </div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground"> {tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span> Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</p>)} </p>)}
</div> </div>
<div className="border-t" /> <div className="border-t"/>
<div className="space-y-2"> <div className="space-y-2">
@@ -345,7 +399,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<Label className="text-sm">Filename Format</Label> <Label className="text-sm">Filename Format</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
@@ -368,7 +422,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))} {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent> </SelectContent>
</Select> </Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1" />)} {tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div> </div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground"> {tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span> Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
@@ -380,11 +434,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
<div className="flex gap-2 justify-between pt-4 border-t"> <div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5"> <Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4"/>
Reset to Default Reset to Default
</Button> </Button>
<Button onClick={handleSave} className="gap-1.5"> <Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" /> <Save className="h-4 w-4"/>
Save Changes Save Changes
</Button> </Button>
</div> </div>
@@ -405,35 +459,18 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={showFFmpegWarning} onOpenChange={(open) => !isInstallingFFmpeg && setShowFFmpegWarning(open)}> <Dialog open={showHiResWarning} onOpenChange={setShowHiResWarning}>
<DialogContent className="max-w-md [&>button]:hidden"> <DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>FFmpeg Required</DialogTitle> <DialogTitle>24-bit Quality Warning</DialogTitle>
<DialogDescription className="space-y-4 pt-2"> <DialogDescription className="pt-2">
<div className="space-y-2"> If 24-bit is unavailable, downloads will automatically fallback to 16-bit.
<p>Tidal 24-bit (Hi-Res Lossless) downloads audio in segmented files that need to be merged into a single FLAC file.</p>
<p>FFmpeg is required to merge these segments. {isInstallingFFmpeg ? "Installing FFmpeg..." : "Would you like to install FFmpeg now?"}</p>
</div>
{isInstallingFFmpeg && (<div className="space-y-2 py-2">
<div className="flex justify-between text-xs font-medium">
<div className="flex flex-col gap-1">
<span>Downloading & Extracting...</span>
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-muted-foreground font-normal">
{downloadProgress.mb_downloaded.toFixed(2)} MB
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`}
</span>)}
</div>
<span>{installProgress}%</span>
</div>
<Progress value={installProgress} className="h-2" />
</div>)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!isInstallingFFmpeg && (<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowFFmpegWarning(false)}>Cancel</Button> <Button variant="outline" onClick={() => setShowHiResWarning(false)}>Disagree</Button>
<Button onClick={handleInstallFFmpeg}>Install FFmpeg</Button> <Button onClick={handleConfirmHiRes}>Agree</Button>
</DialogFooter>)} </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>); </div>);
+22 -26
View File
@@ -1,16 +1,16 @@
import { HomeIcon } from "@/components/ui/home"; import { HomeIcon } from "@/components/ui/home";
import { HistoryIcon } from "@/components/ui/history-icon";
import { SettingsIcon } from "@/components/ui/settings"; import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity"; import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal"; import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon } from "@/components/ui/file-music"; import { FileMusicIcon } from "@/components/ui/file-music";
import { FilePenIcon } from "@/components/ui/file-pen"; import { FilePenIcon } from "@/components/ui/file-pen";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks";
import { CoffeeIcon } from "@/components/ui/coffee"; import { CoffeeIcon } from "@/components/ui/coffee";
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager"; export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
interface SidebarProps { interface SidebarProps {
currentPage: PageType; currentPage: PageType;
onPageChange: (page: PageType) => void; onPageChange: (page: PageType) => void;
@@ -30,19 +30,17 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}> <Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
<SettingsIcon size={20}/> <HistoryIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Settings</p> <p>Download History</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}> <Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
@@ -54,7 +52,6 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}> <Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
@@ -66,7 +63,6 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}> <Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
@@ -78,45 +74,45 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}> <Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
<TerminalIcon size={20}/> <TerminalIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Debug Logs</p> <p>Debug Logs</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
<SettingsIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Settings</p>
</TooltipContent>
</Tooltip>
</div> </div>
<div className="mt-auto flex flex-col gap-2"> <div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?title=%5BBug%20Report%5D%20/%20%5BFeature%20Request%5D&body=%3C%21--%20WARNING%3A%20Issues%20that%20do%20not%20follow%20this%20template%20will%20be%20closed%20without%20review.%20Fill%20out%20the%20relevant%20section%20and%20delete%20the%20other.%20--%3E%0A%0A%23%23%23%20%5BBug%20Report%5D%0A%0A%23%23%23%23%20Problem%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%23%20Spotify%20URL%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Version%0ASpotiFLAC%20v%0A%0A%23%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot%0A%0A---%0A%0A%23%23%23%20%5BFeature%20Request%5D%0A%0A%23%23%23%23%20Description%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Use%20Case%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot")}> <Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<GithubIcon size={20}/> <BadgeAlertIcon size={20}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>Report Bug or Feature Request</p> <p>About</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://exyezed.cc/")}>
<BlocksIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Other Projects</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}> <Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20}/> <CoffeeIcon size={20} loop={true}/>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@@ -0,0 +1,61 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface BadgeAlertIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ICON_VARIANTS: Variants = {
normal: { scale: 1, rotate: 0 },
animate: {
scale: [1, 1.1, 1.1, 1.1, 1],
rotate: [0, -3, 3, -2, 2, 0],
transition: {
duration: 0.5,
times: [0, 0.2, 0.4, 0.6, 1],
ease: "easeInOut",
},
},
};
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
<line x1="12" x2="12" y1="8" y2="12"/>
<line x1="12" x2="12.01" y1="16" y2="16"/>
</motion.svg>
</div>);
});
BadgeAlertIcon.displayName = "BadgeAlertIcon";
export { BadgeAlertIcon };
-52
View File
@@ -1,52 +0,0 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface BlocksIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const VARIANTS: Variants = {
normal: { translateX: 0, translateY: 0 },
animate: { translateX: -4, translateY: 4 },
};
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
<motion.path d="M14 3h7v7h-7z" variants={VARIANTS} animate={controls}/>
</svg>
</div>);
});
BlocksIcon.displayName = 'BlocksIcon';
export { BlocksIcon };
+5 -4
View File
@@ -10,6 +10,7 @@ export interface CoffeeIconHandle {
} }
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> { interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
loop?: boolean;
} }
const PATH_VARIANTS: Variants = { const PATH_VARIANTS: Variants = {
normal: { normal: {
@@ -27,7 +28,7 @@ const PATH_VARIANTS: Variants = {
}, },
}), }),
}; };
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
const controls = useAnimation(); const controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
@@ -55,9 +56,9 @@ const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter
}, [controls, onMouseLeave]); }, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}> return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}> <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
<motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/> <motion.path d="M10 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.2}/>
<motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/> <motion.path d="M14 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.4}/>
<motion.path d="M6 2v2" animate={controls} variants={PATH_VARIANTS} custom={0}/> <motion.path d="M6 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0}/>
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/> <path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
</svg> </svg>
</div>); </div>);
-102
View File
@@ -1,102 +0,0 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.9, 1],
transition: {
duration: 0.4,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
draw: {
pathLength: [0, 1],
rotate: 0,
transition: {
duration: 0.5,
},
},
wag: {
pathLength: 1,
rotate: [0, -15, 15, -10, 10, -5, 5],
transition: {
duration: 2.5,
ease: 'easeInOut',
repeat: Infinity,
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
},
stopAnimation: () => {
bodyControls.start('normal');
tailControls.start('normal');
},
};
});
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
}
else {
onMouseEnter?.(e);
}
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('normal');
tailControls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [bodyControls, tailControls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path variants={BODY_VARIANTS} initial="normal" animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
<motion.path variants={TAIL_VARIANTS} initial="normal" animate={tailControls} d="M9 18c-4.51 2-5-2-7-2"/>
</svg>
</div>);
});
GithubIcon.displayName = 'GithubIcon';
export { GithubIcon };
@@ -0,0 +1,97 @@
"use client";
import type { Transition, Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface HistoryIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HistoryIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ARROW_TRANSITION: Transition = {
type: "spring",
stiffness: 250,
damping: 25,
};
const ARROW_VARIANTS: Variants = {
normal: {
rotate: "0deg",
},
animate: {
rotate: "-50deg",
},
};
const HAND_TRANSITION: Transition = {
duration: 0.6,
ease: [0.4, 0, 0.2, 1],
};
const HAND_VARIANTS: Variants = {
normal: {
rotate: 0,
originX: "0%",
originY: "100%",
},
animate: {
rotate: -360,
originX: "0%",
originY: "100%",
},
};
const MINUTE_HAND_TRANSITION: Transition = {
duration: 0.5,
ease: "easeInOut",
};
const MINUTE_HAND_VARIANTS: Variants = {
normal: {
rotate: 0,
originX: "0%",
originY: "0%",
},
animate: {
rotate: -45,
originX: "0%",
originY: "0%",
},
};
const HistoryIcon = forwardRef<HistoryIconHandle, HistoryIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<motion.g animate={controls} transition={ARROW_TRANSITION} variants={ARROW_VARIANTS}>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</motion.g>
<motion.line animate={controls} initial="normal" transition={HAND_TRANSITION} variants={HAND_VARIANTS} x1="12" x2="12" y1="12" y2="7"/>
<motion.line animate={controls} initial="normal" transition={MINUTE_HAND_TRANSITION} variants={MINUTE_HAND_VARIANTS} x1="12" x2="16" y1="12" y2="14"/>
</svg>
</div>);
});
HistoryIcon.displayName = "HistoryIcon";
export { HistoryIcon };
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props}/>);
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (<TabsPrimitive.List data-slot="tabs-list" className={cn("bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", className)} {...props}/>);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (<TabsPrimitive.Trigger data-slot="tabs-trigger" className={cn("inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm flex-1", className)} {...props}/>);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (<TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props}/>);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
+3 -2
View File
@@ -10,6 +10,7 @@ export interface TerminalIconHandle {
} }
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> { interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number; size?: number;
loop?: boolean;
} }
const LINE_VARIANTS: Variants = { const LINE_VARIANTS: Variants = {
normal: { opacity: 1 }, normal: { opacity: 1 },
@@ -22,7 +23,7 @@ const LINE_VARIANTS: Variants = {
}, },
}, },
}; };
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
const controls = useAnimation(); const controls = useAnimation();
const isControlledRef = useRef(false); const isControlledRef = useRef(false);
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
@@ -51,7 +52,7 @@ const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMous
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}> return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 17 10 11 4 5"/> <polyline points="4 17 10 11 4 5"/>
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={controls} initial="normal"/> <motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={loop ? 'animate' : controls} initial="normal"/>
</svg> </svg>
</div>); </div>);
}); });
+6
View File
@@ -0,0 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (<textarea data-slot="textarea" className={cn("border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className)} {...props}/>);
}
export { Textarea };
-1
View File
@@ -1,4 +1,3 @@
"use client";
import * as React from "react"; import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle"; import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
+94 -47
View File
@@ -149,10 +149,16 @@ export function useDownload() {
} }
} }
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
if (streamingURLs?.tidal_url) { const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" };
const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try { try {
logger.debug(`trying tidal for: ${trackName} - ${artistName}`); logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
const tidalResponse = await downloadTrack({ const response = await downloadTrack({
isrc, isrc,
service: "tidal", service: "tidal",
query, query,
@@ -173,7 +179,7 @@ export function useDownload() {
service_url: streamingURLs.tidal_url, service_url: streamingURLs.tidal_url,
duration: durationSeconds, duration: durationSeconds,
item_id: itemID, item_id: itemID,
audio_format: settings.tidalQuality || "LOSSLESS", audio_format: tidalQuality,
spotify_track_number: spotifyTrackNumber, spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber, spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks, spotify_total_tracks: spotifyTotalTracks,
@@ -181,20 +187,22 @@ export function useDownload() {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
}); });
if (tidalResponse.success) { if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`); logger.success(`tidal: ${trackName} - ${artistName}`);
return tidalResponse; return response;
} }
logger.warning(`tidal failed, trying amazon...`); lastResponse = response;
logger.warning(`tidal failed, trying next...`);
} }
catch (tidalErr) { catch (err) {
logger.error(`tidal error: ${tidalErr}`); logger.error(`tidal error: ${err}`);
lastResponse = { success: false, error: String(err) };
} }
} }
if (streamingURLs?.amazon_url) { else if (s === "amazon" && streamingURLs?.amazon_url) {
try { try {
logger.debug(`trying amazon for: ${trackName} - ${artistName}`); logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
const amazonResponse = await downloadTrack({ const response = await downloadTrack({
isrc, isrc,
service: "amazon", service: "amazon",
query, query,
@@ -221,18 +229,22 @@ export function useDownload() {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
}); });
if (amazonResponse.success) { if (response.success) {
logger.success(`amazon: ${trackName} - ${artistName}`); logger.success(`amazon: ${trackName} - ${artistName}`);
return amazonResponse; return response;
} }
logger.warning(`amazon failed, trying qobuz...`); lastResponse = response;
logger.warning(`amazon failed, trying next...`);
} }
catch (amazonErr) { catch (err) {
logger.error(`amazon error: ${amazonErr}`); logger.error(`amazon error: ${err}`);
lastResponse = { success: false, error: String(err) };
} }
} }
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`); else if (s === "qobuz") {
const qobuzResponse = await downloadTrack({ try {
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
isrc, isrc,
service: "qobuz", service: "qobuz",
query, query,
@@ -250,9 +262,8 @@ export function useDownload() {
spotify_id: spotifyId, spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics, embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover, embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID, item_id: itemID,
audio_format: settings.qobuzQuality || "6", audio_format: qobuzQuality,
spotify_track_number: spotifyTrackNumber, spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber, spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks, spotify_total_tracks: spotifyTotalTracks,
@@ -260,11 +271,24 @@ export function useDownload() {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
}); });
if (!qobuzResponse.success && itemID) { if (response.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); logger.success(`qobuz: ${trackName} - ${artistName}`);
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed"); return response;
} }
return qobuzResponse; lastResponse = response;
logger.warning(`qobuz failed, trying next...`);
}
catch (err) {
logger.error(`qobuz error: ${err}`);
lastResponse = { success: false, error: String(err) };
}
}
}
if (itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
}
return lastResponse;
} }
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
let audioFormat: string | undefined; let audioFormat: string | undefined;
@@ -375,9 +399,15 @@ export function useDownload() {
} }
} }
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
if (streamingURLs?.tidal_url) { const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
let lastResponse: any = { success: false, error: "No matching services found" };
const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "7" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try { try {
const tidalResponse = await downloadTrack({ const response = await downloadTrack({
isrc, isrc,
service: "tidal", service: "tidal",
query, query,
@@ -398,7 +428,7 @@ export function useDownload() {
service_url: streamingURLs.tidal_url, service_url: streamingURLs.tidal_url,
duration: durationSeconds, duration: durationSeconds,
item_id: itemID, item_id: itemID,
audio_format: settings.tidalQuality || "LOSSLESS", audio_format: tidalQuality,
spotify_track_number: spotifyTrackNumber, spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber, spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks, spotify_total_tracks: spotifyTotalTracks,
@@ -406,17 +436,19 @@ export function useDownload() {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
}); });
if (tidalResponse.success) { if (response.success) {
return tidalResponse; return response;
}
lastResponse = response;
}
catch (err) {
console.error("Tidal error:", err);
lastResponse = { success: false, error: String(err) };
} }
} }
catch (tidalErr) { else if (s === "amazon" && streamingURLs?.amazon_url) {
console.error("Tidal error:", tidalErr);
}
}
if (streamingURLs?.amazon_url) {
try { try {
const amazonResponse = await downloadTrack({ const response = await downloadTrack({
isrc, isrc,
service: "amazon", service: "amazon",
query, query,
@@ -443,15 +475,19 @@ export function useDownload() {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
}); });
if (amazonResponse.success) { if (response.success) {
return amazonResponse; return response;
}
lastResponse = response;
}
catch (err) {
console.error("Amazon error:", err);
lastResponse = { success: false, error: String(err) };
} }
} }
catch (amazonErr) { else if (s === "qobuz") {
console.error("Amazon error:", amazonErr); try {
} const response = await downloadTrack({
}
const qobuzResponse = await downloadTrack({
isrc, isrc,
service: "qobuz", service: "qobuz",
query, query,
@@ -469,9 +505,9 @@ export function useDownload() {
spotify_id: spotifyId, spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics, embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover, embed_max_quality_cover: settings.embedMaxQualityCover,
duration: durationMs ? Math.round(durationMs / 1000) : undefined, duration: durationSeconds,
item_id: itemID, item_id: itemID,
audio_format: settings.qobuzQuality || "6", audio_format: qobuzQuality,
spotify_track_number: spotifyTrackNumber, spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber, spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks, spotify_total_tracks: spotifyTotalTracks,
@@ -479,11 +515,22 @@ export function useDownload() {
copyright: copyright, copyright: copyright,
publisher: publisher, publisher: publisher,
}); });
if (!qobuzResponse.success && itemID) { if (response.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); return response;
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
} }
return qobuzResponse; lastResponse = response;
}
catch (err) {
console.error("Qobuz error:", err);
lastResponse = { success: false, error: String(err) };
}
}
}
if (!lastResponse.success && itemID) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
}
return lastResponse;
} }
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
let audioFormat: string | undefined; let audioFormat: string | undefined;
+1 -1
View File
@@ -12,7 +12,7 @@ class Logger {
const entry: LogEntry = { const entry: LogEntry = {
timestamp: new Date(), timestamp: new Date(),
level, level,
message: message.toLowerCase(), message: message,
}; };
this.logs.push(entry); this.logs.push(entry);
if (this.logs.length > this.maxLogs) { if (this.logs.length > this.maxLogs) {
+18 -2
View File
@@ -23,6 +23,8 @@ export interface Settings {
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7"; qobuzQuality: "6" | "7";
amazonQuality: "original"; amazonQuality: "original";
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
autoQuality: "16" | "24";
} }
export const FOLDER_PRESETS: Record<FolderPreset, { export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
@@ -95,7 +97,9 @@ export const DEFAULT_SETTINGS: Settings = {
operatingSystem: detectOS(), operatingSystem: detectOS(),
tidalQuality: "LOSSLESS", tidalQuality: "LOSSLESS",
qobuzQuality: "6", qobuzQuality: "6",
amazonQuality: "original" amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon",
autoQuality: "16"
}; };
export const FONT_OPTIONS: { export const FONT_OPTIONS: {
value: FontFamily; value: FontFamily;
@@ -119,7 +123,7 @@ export const FONT_OPTIONS: {
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' }, { value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' }, { value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' }, { value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
]; ];
export function applyFont(fontFamily: FontFamily): void { export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily); const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) { if (font) {
@@ -196,6 +200,12 @@ function getSettingsFromLocalStorage(): Settings {
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original"; parsed.amazonQuality = "original";
} }
if (!('autoOrder' in parsed)) {
parsed.autoOrder = "tidal-qobuz-amazon";
}
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
return { ...DEFAULT_SETTINGS, ...parsed }; return { ...DEFAULT_SETTINGS, ...parsed };
} }
} }
@@ -266,6 +276,12 @@ export async function loadSettings(): Promise<Settings> {
if (!('amazonQuality' in parsed)) { if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original"; parsed.amazonQuality = "original";
} }
if (!('autoOrder' in parsed)) {
parsed.autoOrder = "tidal-qobuz-amazon";
}
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!; return cachedSettings!;
} }
+1
View File
@@ -11,6 +11,7 @@ require (
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/ulikunitz/xz v0.5.15 github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
go.etcd.io/bbolt v1.4.3
) )
require ( require (
+4
View File
@@ -86,6 +86,8 @@ github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4X
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
@@ -99,6 +101,8 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+1
View File
@@ -29,6 +29,7 @@ func main() {
}, },
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255}, BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
OnStartup: app.startup, OnStartup: app.startup,
OnShutdown: app.shutdown,
DragAndDrop: &options.DragAndDrop{ DragAndDrop: &options.DragAndDrop{
EnableFileDrop: true, EnableFileDrop: true,
DisableWebViewDrop: false, DisableWebViewDrop: false,
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.0.5", "productVersion": "7.0.6",
"copyright": "© 2026 afkarxyz" "copyright": "© 2026 afkarxyz"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",