v7.0.6
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
68754ba75ba7fe058dd9ebf6593e2759
|
42597f825aff483763c8cb00c83bfa74
|
||||||
Generated
+32
@@ -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
@@ -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>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
}
|
||||||
@@ -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>);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
|
||||||
@@ -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>);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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>);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user