v7.1.1
This commit is contained in:
@@ -5,10 +5,12 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"path/filepath"
|
||||
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -55,6 +57,7 @@ type SpotifyMetadataRequest struct {
|
||||
Batch bool `json:"batch"`
|
||||
Delay float64 `json:"delay"`
|
||||
Timeout float64 `json:"timeout"`
|
||||
Separator string `json:"separator,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadRequest struct {
|
||||
@@ -91,6 +94,7 @@ type DownloadRequest struct {
|
||||
UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"`
|
||||
UseSingleGenre bool `json:"use_single_genre,omitempty"`
|
||||
EmbedGenre bool `json:"embed_genre,omitempty"`
|
||||
Separator string `json:"separator,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
@@ -342,7 +346,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if req.EmbedLyrics {
|
||||
go func() {
|
||||
client := backend.NewLyricsClient()
|
||||
resp, _, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.Duration)
|
||||
resp, _, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, req.Duration)
|
||||
if err == nil && resp != nil && len(resp.Lines) > 0 {
|
||||
lrc := client.ConvertToLRC(resp, req.TrackName, req.ArtistName)
|
||||
lyricsChan <- lrc
|
||||
@@ -402,10 +406,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
|
||||
case "deezer":
|
||||
downloader := backend.NewDeezerDownloader()
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
|
||||
default:
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
@@ -548,6 +548,18 @@ func (a *App) OpenFolder(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) OpenConfigFolder() error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %v", err)
|
||||
}
|
||||
configDir := filepath.Join(homeDir, ".spotiflac")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
return backend.OpenFolderInExplorer(configDir)
|
||||
}
|
||||
|
||||
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
||||
}
|
||||
@@ -660,6 +672,52 @@ func (a *App) ExportFailedDownloads() (string, error) {
|
||||
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
|
||||
}
|
||||
|
||||
func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
var checkURL string
|
||||
if apiType == "tidal" {
|
||||
checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)
|
||||
} else if apiType == "qobuz" {
|
||||
checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&format_id=27", apiURL)
|
||||
} else if apiType == "qbz" {
|
||||
checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL)
|
||||
} else if apiType == "amazon" {
|
||||
checkURL = fmt.Sprintf("%s/status", apiURL)
|
||||
} else {
|
||||
checkURL = apiURL
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequest("GET", checkURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
maxRetries := 3
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
statusCode := resp.StatusCode
|
||||
if apiType == "amazon" && statusCode == 200 {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr == nil && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
if statusCode == 200 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) Quit() {
|
||||
|
||||
panic("quit")
|
||||
@@ -1088,23 +1146,6 @@ func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
func (a *App) UploadImage(filePath string) (string, error) {
|
||||
return backend.UploadToSendNow(filePath)
|
||||
}
|
||||
|
||||
func (a *App) UploadImageBytes(filename string, base64Data string) (string, error) {
|
||||
|
||||
if idx := strings.Index(base64Data, ","); idx != -1 {
|
||||
base64Data = base64Data[idx+1:]
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64: %v", err)
|
||||
}
|
||||
return backend.UploadBytesToSendNow(filename, data)
|
||||
}
|
||||
|
||||
func (a *App) SelectImageVideo() ([]string, error) {
|
||||
return backend.SelectImageVideoDialog(a.ctx)
|
||||
}
|
||||
@@ -1370,10 +1411,6 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
|
||||
return backend.IsFFmpegInstalled()
|
||||
}
|
||||
|
||||
func (a *App) GetOSInfo() (string, error) {
|
||||
return backend.GetOSInfo()
|
||||
}
|
||||
|
||||
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
|
||||
if len(filePaths) == 0 {
|
||||
return nil
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".cover.jpg"
|
||||
return filename + ".jpg"
|
||||
}
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeezerDownloader struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewDeezerDownloader() *DeezerDownloader {
|
||||
return &DeezerDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 300 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type YoinkifyRequest struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
GenreSource string `json:"genreSource"`
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) DownloadFromYoinkify(spotifyURL, outputDir string) (string, error) {
|
||||
apiURL := "https://yoinkify.lol/api/download"
|
||||
|
||||
payload := YoinkifyRequest{
|
||||
URL: spotifyURL,
|
||||
Format: "flac",
|
||||
GenreSource: "spotify",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
fmt.Printf("Fetching from Deezer API (Yoinkify)...\n")
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tempFileName := fmt.Sprintf("deezer_%d.flac", time.Now().UnixNano())
|
||||
filePath := filepath.Join(outputDir, tempFileName)
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
fmt.Printf("Downloading track from Deezer...\n")
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(filePath)
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) Download(spotifyID, outputDir, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
filenameArtist := spotifyArtistName
|
||||
filenameAlbumArtist := spotifyAlbumArtist
|
||||
if useFirstArtistOnly {
|
||||
filenameArtist = GetFirstArtist(spotifyArtistName)
|
||||
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
|
||||
}
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
|
||||
return "EXISTS:" + expectedPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
type mbResult struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if (embedGenre || true) && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
parts := strings.Split(spotifyURL, "/")
|
||||
if len(parts) > 0 {
|
||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||
if sID != "" {
|
||||
client := NewSongLinkClient()
|
||||
if val, err := client.GetISRC(sID); err == nil {
|
||||
isrc = val
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ISRC = isrc
|
||||
if isrc != "" && embedGenre {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
metaChan <- res
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
filePath, err := d.DownloadFromYoinkify(spotifyURL, outputDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var isrc string
|
||||
var mbMeta Metadata
|
||||
if spotifyURL != "" {
|
||||
result := <-metaChan
|
||||
isrc = result.ISRC
|
||||
mbMeta = result.Metadata
|
||||
}
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
if useFirstArtistOnly {
|
||||
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||
|
||||
year := ""
|
||||
if len(spotifyReleaseDate) >= 4 {
|
||||
year = spotifyReleaseDate[:4]
|
||||
}
|
||||
|
||||
var newFilename string
|
||||
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
newFilename = filenameFormat
|
||||
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
||||
|
||||
if spotifyDiscNumber > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||
} else {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
|
||||
}
|
||||
|
||||
if position > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{track}", "")
|
||||
}
|
||||
} else {
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
newFilename = safeTitle
|
||||
default:
|
||||
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
if includeTrackNumber && position > 0 {
|
||||
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
||||
}
|
||||
}
|
||||
|
||||
ext := ".flac"
|
||||
newFilename = newFilename + ext
|
||||
newFilePath := filepath.Join(outputDir, newFilename)
|
||||
|
||||
if err := os.Rename(filePath, newFilePath); err != nil {
|
||||
fmt.Printf("Warning: Failed to rename file: %v\n", err)
|
||||
} else {
|
||||
filePath = newFilePath
|
||||
fmt.Printf("Renamed to: %s\n", newFilename)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Embedding Spotify metadata...")
|
||||
|
||||
coverPath := ""
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filePath + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||
coverPath = ""
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
fmt.Println("Spotify cover downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: spotifyTrackName,
|
||||
Artist: spotifyArtistName,
|
||||
Album: spotifyAlbumName,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Metadata embedded successfully")
|
||||
}
|
||||
|
||||
fmt.Println("Done")
|
||||
fmt.Println("✓ Downloaded successfully from Deezer")
|
||||
return filePath, nil
|
||||
}
|
||||
+1
-45
@@ -81,28 +81,6 @@ func GetFFmpegPath() (string, error) {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||
homebrewPath := "/opt/homebrew/bin/" + ffmpegName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||
homebrewPath := "/usr/local/bin/" + ffmpegName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
path, err := exec.Command("which", ffmpegName).Output()
|
||||
if err == nil {
|
||||
trimmed := strings.TrimSpace(string(path))
|
||||
if trimmed != "" {
|
||||
return trimmed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(ffmpegName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
@@ -127,28 +105,6 @@ func GetFFprobePath() (string, error) {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||
homebrewPath := "/opt/homebrew/bin/" + ffprobeName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||
homebrewPath := "/usr/local/bin/" + ffprobeName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
path, err := exec.Command("which", ffprobeName).Output()
|
||||
if err == nil {
|
||||
trimmed := strings.TrimSpace(string(path))
|
||||
if trimmed != "" {
|
||||
return trimmed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(ffprobeName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
@@ -295,7 +251,7 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
+27
-1
@@ -1,7 +1,9 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -133,10 +135,34 @@ func GetFirstArtist(artistString string) string {
|
||||
}
|
||||
|
||||
func NormalizePath(folderPath string) string {
|
||||
|
||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
}
|
||||
|
||||
func GetSeparator() string {
|
||||
dir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "; "
|
||||
}
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return "; "
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if sep, ok := settings["separator"].(string); ok {
|
||||
if sep == "comma" {
|
||||
return ", "
|
||||
}
|
||||
if sep == "semicolon" {
|
||||
return "; "
|
||||
}
|
||||
}
|
||||
}
|
||||
return "; "
|
||||
}
|
||||
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
|
||||
+98
-115
@@ -37,17 +37,6 @@ type LyricsResponse struct {
|
||||
Lines []LyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsLine struct {
|
||||
TimeTag string `json:"timeTag"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsAPIResponse struct {
|
||||
Error bool `json:"error"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []SpotifyLyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -81,12 +70,16 @@ func NewLyricsClient() *LyricsClient {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName string, duration int) (*LyricsResponse, error) {
|
||||
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
if albumName != "" {
|
||||
apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName))
|
||||
}
|
||||
|
||||
if duration > 0 {
|
||||
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||
}
|
||||
@@ -111,6 +104,10 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, dur
|
||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
if lrcLibResp.SyncedLyrics == "" && lrcLibResp.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("LRCLIB returned empty lyrics")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||
}
|
||||
|
||||
@@ -174,8 +171,10 @@ func lrcTimestampToMs(timestamp string) int64 {
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/search?q=%s", url.QueryEscape(query))
|
||||
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
@@ -201,79 +200,35 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
|
||||
return nil, fmt.Errorf("no results found")
|
||||
}
|
||||
|
||||
var best *LRCLibResponse
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
for i := range results {
|
||||
if results[i].SyncedLyrics != "" {
|
||||
best = &results[i]
|
||||
break
|
||||
if results[i].SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = &results[i]
|
||||
}
|
||||
if best == nil && results[i].PlainLyrics != "" {
|
||||
best = &results[i]
|
||||
if results[i].PlainLyrics != "" && bestPlain == nil {
|
||||
bestPlain = &results[i]
|
||||
}
|
||||
if bestSynced != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
best := bestSynced
|
||||
if best == nil {
|
||||
best = bestPlain
|
||||
}
|
||||
if best == nil {
|
||||
best = &results[0]
|
||||
}
|
||||
|
||||
if best.SyncedLyrics == "" && best.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("no lyrics found in search results")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(best), nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
if spotifyID == "" {
|
||||
return nil, fmt.Errorf("spotify ID is empty")
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", spotifyID)
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %v", err)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %v", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned error")
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Error: false,
|
||||
SyncType: apiResp.SyncType,
|
||||
Lines: []LyricsLine{},
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
if line.TimeTag == "" && line.Words == "" {
|
||||
continue
|
||||
}
|
||||
ms := lrcTimestampToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: fmt.Sprintf("%d", ms),
|
||||
Words: line.Words,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func simplifyTrackName(name string) string {
|
||||
|
||||
if idx := strings.Index(name, "("); idx > 0 {
|
||||
@@ -286,41 +241,88 @@ func simplifyTrackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
|
||||
|
||||
resp, err := c.FetchLyricsFromSpotifyAPI(spotifyID)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "Spotify", nil
|
||||
func isSynced(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0
|
||||
}
|
||||
fmt.Printf(" Spotify Lyrics API: %v\n", err)
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(trackName, artistName, duration)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB", nil
|
||||
func hasLyrics(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && len(resp.Lines) > 0
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
||||
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search", nil
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) {
|
||||
|
||||
var unsyncedFallback *LyricsResponse
|
||||
var unsyncedSource string
|
||||
|
||||
check := func(resp *LyricsResponse, err error, source string) (*LyricsResponse, string, bool) {
|
||||
if err != nil || resp == nil || resp.Error || len(resp.Lines) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: %v\n", err)
|
||||
if isSynced(resp) {
|
||||
return resp, source, true
|
||||
}
|
||||
|
||||
if unsyncedFallback == nil {
|
||||
unsyncedFallback = resp
|
||||
unsyncedSource = source
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
var resp *LyricsResponse
|
||||
var src string
|
||||
var found bool
|
||||
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (with album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (with album): no synced\n")
|
||||
|
||||
if albumName != "" {
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, "", duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (no album)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (no album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (no album): no synced\n")
|
||||
}
|
||||
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: no synced\n")
|
||||
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB (simplified)", nil
|
||||
resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified exact\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search (simplified)", nil
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
}
|
||||
|
||||
if unsyncedFallback != nil {
|
||||
fmt.Printf(" [LRCLIB] No synced found, using unsynced from: %s\n", unsyncedSource)
|
||||
return unsyncedFallback, unsyncedSource + " (unsynced)", nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||
}
|
||||
|
||||
@@ -472,25 +474,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
safeArtist := sanitizeFilename(req.AlbumArtist)
|
||||
if safeArtist == "" {
|
||||
safeArtist = sanitizeFilename(req.ArtistName)
|
||||
}
|
||||
safeAlbum := sanitizeFilename(req.AlbumName)
|
||||
|
||||
if safeArtist != "" && safeAlbum != "" {
|
||||
artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum)
|
||||
if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() {
|
||||
outputDir = artistAlbumPath
|
||||
} else {
|
||||
|
||||
artistPath := filepath.Join(outputDir, safeArtist)
|
||||
if info, err := os.Stat(artistPath); err == nil && info.IsDir() {
|
||||
outputDir = artistPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
@@ -524,7 +507,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}
|
||||
}
|
||||
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration)
|
||||
if err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
|
||||
@@ -146,7 +146,7 @@ func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre
|
||||
if len(genres) > 5 {
|
||||
genres = genres[:5]
|
||||
}
|
||||
meta.Genre = strings.Join(genres, "; ")
|
||||
meta.Genre = strings.Join(genres, GetSeparator())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -118,8 +118,15 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
}
|
||||
|
||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||
if strings.Contains(apiBase, "qbz.afkarxyz.fun") {
|
||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||
}
|
||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||
apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||
resp, err := q.client.Get(apiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -167,6 +174,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
standardAPIs := []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
"https://qbz.afkarxyz.fun/api/track/",
|
||||
}
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
|
||||
+10
-10
@@ -665,7 +665,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||
}
|
||||
if albumArtistsString == "" {
|
||||
albumArtistsString = getString(albumUnionData, "artists")
|
||||
@@ -681,7 +681,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,13 +715,13 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range artists {
|
||||
artistNames = append(artistNames, getString(artist, "name"))
|
||||
}
|
||||
artistsString := strings.Join(artistNames, ", ")
|
||||
artistsString := strings.Join(artistNames, GetSeparator())
|
||||
|
||||
copyrightTexts := []string{}
|
||||
for _, item := range copyrightInfo {
|
||||
copyrightTexts = append(copyrightTexts, getString(item, "text"))
|
||||
}
|
||||
copyrightString := strings.Join(copyrightTexts, ", ")
|
||||
copyrightString := strings.Join(copyrightTexts, GetSeparator())
|
||||
|
||||
discNumber := int(getFloat64(trackData, "discNumber"))
|
||||
if discNumber == 0 {
|
||||
@@ -814,7 +814,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range artists {
|
||||
artistNames = append(artistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString := strings.Join(artistNames, ", ")
|
||||
albumArtistsString := strings.Join(artistNames, GetSeparator())
|
||||
|
||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||
var cover interface{}
|
||||
@@ -875,7 +875,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
||||
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||
|
||||
trackURI := getString(track, "uri")
|
||||
trackID := ""
|
||||
@@ -1075,7 +1075,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
artistsString := strings.Join(trackArtistNames, ", ")
|
||||
artistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||
|
||||
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
||||
durationObj := extractDuration(trackDurationMs)
|
||||
@@ -1121,7 +1121,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,7 +1514,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
||||
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
||||
|
||||
durationString := getString(trackDuration, "formatted")
|
||||
|
||||
@@ -1586,7 +1586,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString := strings.Join(albumArtistNames, ", ")
|
||||
albumArtistsString := strings.Join(albumArtistNames, GetSeparator())
|
||||
|
||||
dateInfo := getMap(album, "date")
|
||||
var year interface{}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetOSInfo() (string, error) {
|
||||
osType := runtime.GOOS
|
||||
arch := runtime.GOARCH
|
||||
|
||||
switch osType {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func GetOSInfo() (string, error) {
|
||||
arch := runtime.GOARCH
|
||||
|
||||
cmd := exec.Command("wmic", "os", "get", "Caption,Version", "/value")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
cmdVer := exec.Command("cmd", "/c", "ver")
|
||||
cmdVer.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
outVer, errVer := cmdVer.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
|
||||
}
|
||||
+5
-1
@@ -79,9 +79,13 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
apis := []string{
|
||||
"https://triton.squid.wtf",
|
||||
"https://hifi-one.spotisaver.net",
|
||||
"https://hifi-two.spotisaver.net",
|
||||
"https://eu-central.monochrome.tf",
|
||||
"https://us-west.monochrome.tf",
|
||||
"https://api.monochrome.tf",
|
||||
"https://monochrome-api.samidy.com",
|
||||
"https://tidal.kinoplus.online",
|
||||
}
|
||||
return apis, nil
|
||||
}
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SendNowResponse []struct {
|
||||
FileCode string `json:"file_code"`
|
||||
}
|
||||
|
||||
func UploadToSendNow(filePath string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return uploadToService(filepath.Base(filePath), file)
|
||||
}
|
||||
|
||||
func UploadBytesToSendNow(filename string, data []byte) (string, error) {
|
||||
return uploadToService(filename, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func uploadToService(filename string, fileReader io.Reader) (string, error) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
fields := map[string]string{
|
||||
"sess_id": "",
|
||||
"utype": "anon",
|
||||
"hidden": "",
|
||||
"enableemail": "",
|
||||
"link_rcpt": "",
|
||||
"link_pass": "",
|
||||
"file_expire_time": "",
|
||||
"file_expire_unit": "DAY",
|
||||
"file_max_dl": "1",
|
||||
"file_public": "1",
|
||||
"keepalive": "1",
|
||||
}
|
||||
|
||||
for key, val := range fields {
|
||||
if err := writer.WriteField(key, val); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
part, err := writer.CreateFormFile("file_0", filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(part, fileReader); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
|
||||
uploadURL, err := getUploadURL()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get upload server: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", uploadURL, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Origin", "https://send.now")
|
||||
req.Header.Set("Referer", "https://send.now/")
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("server error %d: %s", resp.StatusCode, string(respBytes))
|
||||
}
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result SendNowResponse
|
||||
if err := json.Unmarshal(respBytes, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %v, raw: %s", err, string(respBytes))
|
||||
}
|
||||
|
||||
if len(result) == 0 || result[0].FileCode == "" {
|
||||
return "", fmt.Errorf("invalid response format")
|
||||
}
|
||||
|
||||
fileCode := result[0].FileCode
|
||||
downloadLink := fmt.Sprintf("https://send.now/%s", fileCode)
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if ext == ".mp4" || ext == ".mov" || ext == ".mkv" || ext == ".webm" || ext == ".avi" {
|
||||
return fmt.Sprintf("[Video](%s)", downloadLink), nil
|
||||
}
|
||||
|
||||
return fetchDirectImageLink(downloadLink)
|
||||
}
|
||||
|
||||
func getUploadURL() (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://send.now/", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("failed to fetch main page: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := string(bodyBytes)
|
||||
|
||||
re := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi\?upload_type=file[^"']*)["']`)
|
||||
matches := re.FindStringSubmatch(body)
|
||||
if len(matches) > 1 {
|
||||
return matches[1], nil
|
||||
}
|
||||
|
||||
reFallback := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi)`)
|
||||
matchesFallback := reFallback.FindStringSubmatch(body)
|
||||
if len(matchesFallback) > 1 {
|
||||
return matchesFallback[1] + "?upload_type=file&utype=anon", nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("upload URL not found in main page")
|
||||
}
|
||||
|
||||
func fetchDirectImageLink(url string) (string, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
htmlBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
htmlStr := string(htmlBytes)
|
||||
|
||||
reFullRes := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["'][^>]*title=["']Open image on new tab["']`)
|
||||
matchesFull := reFullRes.FindStringSubmatch(htmlStr)
|
||||
if len(matchesFull) > 1 {
|
||||
return fmt.Sprintf("", matchesFull[1]), nil
|
||||
}
|
||||
|
||||
reClipboard := regexp.MustCompile(`(?s)data-clipboard-text=['"]<a href="[^"]+".*?><img src="([^"]+)"`)
|
||||
matches := reClipboard.FindStringSubmatch(htmlStr)
|
||||
if len(matches) > 1 {
|
||||
return fmt.Sprintf("", matches[1]), nil
|
||||
}
|
||||
|
||||
reImg := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']*?\.send\.now/i/[^"']+)["']`)
|
||||
matchesImg := reImg.FindStringSubmatch(htmlStr)
|
||||
if len(matchesImg) > 1 {
|
||||
return fmt.Sprintf("", matchesImg[1]), nil
|
||||
}
|
||||
|
||||
reAnchor := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
||||
matchesAnchor := reAnchor.FindStringSubmatch(htmlStr)
|
||||
if len(matchesAnchor) > 1 {
|
||||
return fmt.Sprintf("", matchesAnchor[1]), nil
|
||||
}
|
||||
|
||||
reGeneric := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
||||
matchesGeneric := reGeneric.FindAllStringSubmatch(htmlStr, -1)
|
||||
for _, match := range matchesGeneric {
|
||||
if len(match) > 1 {
|
||||
link := match[1]
|
||||
|
||||
if !regexp.MustCompile(`(?i)(logo|icon|button|assets)`).MatchString(filepath.Base(link)) {
|
||||
return fmt.Sprintf("", link), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("[View File](%s)", url), nil
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -1 +1 @@
|
||||
3ca7ac3e41fb33a6fc3e30c16b39657b
|
||||
867c45db7982e126a7249d80210f23be
|
||||
Generated
+669
@@ -68,6 +68,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
radix-ui:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4
|
||||
@@ -616,6 +619,45 @@ packages:
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-accessible-icon@1.1.7':
|
||||
resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==}
|
||||
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-accordion@1.2.12':
|
||||
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
||||
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-alert-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||
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-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
@@ -629,6 +671,32 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-aspect-ratio@1.1.7':
|
||||
resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==}
|
||||
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-avatar@1.1.10':
|
||||
resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==}
|
||||
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-checkbox@1.3.3':
|
||||
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
||||
peerDependencies:
|
||||
@@ -642,6 +710,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12':
|
||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||
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-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -730,6 +811,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16':
|
||||
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
|
||||
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-focus-guards@1.1.3':
|
||||
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
||||
peerDependencies:
|
||||
@@ -752,6 +846,32 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-form@0.1.8':
|
||||
resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==}
|
||||
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-hover-card@1.1.15':
|
||||
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
|
||||
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-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
@@ -761,6 +881,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-label@2.1.7':
|
||||
resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
|
||||
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-label@2.1.8':
|
||||
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
|
||||
peerDependencies:
|
||||
@@ -800,6 +933,58 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-navigation-menu@1.2.14':
|
||||
resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
|
||||
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-one-time-password-field@0.1.8':
|
||||
resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==}
|
||||
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-password-toggle-field@0.1.3':
|
||||
resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==}
|
||||
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-popover@1.1.15':
|
||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||
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-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -865,6 +1050,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-progress@1.1.7':
|
||||
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
|
||||
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-progress@1.1.8':
|
||||
resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
|
||||
peerDependencies:
|
||||
@@ -878,6 +1076,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8':
|
||||
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
|
||||
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-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
@@ -917,6 +1128,32 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.7':
|
||||
resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
|
||||
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-slider@1.3.6':
|
||||
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
|
||||
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-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -961,6 +1198,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toast@1.2.15':
|
||||
resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==}
|
||||
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':
|
||||
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
||||
peerDependencies:
|
||||
@@ -987,6 +1237,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toolbar@1.1.11':
|
||||
resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==}
|
||||
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-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
@@ -1036,6 +1299,15 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-is-hydrated@0.1.0':
|
||||
resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
@@ -1872,6 +2144,19 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
radix-ui@1.4.3:
|
||||
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
|
||||
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
|
||||
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
@@ -2028,6 +2313,11 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2471,6 +2761,46 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -2480,6 +2810,28 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -2496,6 +2848,22 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
@@ -2581,6 +2949,21 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -2598,6 +2981,37 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
@@ -2605,6 +3019,15 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -2658,6 +3081,87 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -2714,6 +3218,16 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
@@ -2724,6 +3238,24 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -2787,6 +3319,34 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
@@ -2832,6 +3392,26 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -2858,6 +3438,21 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -2906,6 +3501,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -3636,6 +4238,69 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -3816,6 +4481,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
|
||||
@@ -336,7 +336,7 @@ function App() {
|
||||
if ("track" in metadata.metadata) {
|
||||
const { track } = metadata.metadata;
|
||||
const trackId = track.spotify_id || "";
|
||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} 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} onBack={metadata.resetMetadata}/>);
|
||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} 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, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
||||
}
|
||||
if ("album_info" in metadata.metadata) {
|
||||
const { album_info, track_list } = metadata.metadata;
|
||||
@@ -415,7 +415,7 @@ function App() {
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "about":
|
||||
return <AboutPage version={CURRENT_VERSION}/>;
|
||||
return <AboutPage />;
|
||||
case "history":
|
||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||
metadata.loadFromCache(cachedData);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 903 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -1,13 +1,8 @@
|
||||
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 { 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, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart, } from "lucide-react";
|
||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck } from "lucide-react";
|
||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||
import XProIcon from "@/assets/x-pro.webp";
|
||||
@@ -15,64 +10,15 @@ import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||
import KofiLogo from "@/assets/kofi_symbol.svg";
|
||||
import KofiLogo from "@/assets/ko-fi.gif";
|
||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||
import { langColors } from "@/assets/github-lang-colors";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { DragDropMedia } from "./DragDropTextarea";
|
||||
interface AboutPageProps {
|
||||
version: string;
|
||||
}
|
||||
export function AboutPage({ version }: AboutPageProps) {
|
||||
const [os, setOs] = useState("Unknown");
|
||||
const [location, setLocation] = useState("Unknown");
|
||||
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
|
||||
const [bugType, setBugType] = useState("Track");
|
||||
const [problem, setProblem] = useState("");
|
||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||
const [bugContext, setBugContext] = useState("");
|
||||
const [featureDesc, setFeatureDesc] = useState("");
|
||||
const [useCase, setUseCase] = useState("");
|
||||
const [featureContext, setFeatureContext] = useState("");
|
||||
export function AboutPage() {
|
||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
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 fetchRepoStats = async () => {
|
||||
const CACHE_KEY = "github_repo_stats";
|
||||
const CACHE_DURATION = 1000 * 60 * 60;
|
||||
@@ -115,7 +61,9 @@ export function AboutPage({ version }: AboutPageProps) {
|
||||
const languages = await langsRes.json();
|
||||
let totalDownloads = 0;
|
||||
let latestDownloads = 0;
|
||||
let latestVersion = "";
|
||||
if (releases.length > 0) {
|
||||
latestVersion = releases[0].tag_name || "";
|
||||
latestDownloads =
|
||||
releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
||||
totalDownloads = releases.reduce((sum: number, release: any) => {
|
||||
@@ -133,6 +81,7 @@ export function AboutPage({ version }: AboutPageProps) {
|
||||
createdAt: repoData.created_at,
|
||||
totalDownloads,
|
||||
latestDownloads,
|
||||
latestVersion,
|
||||
languages: topLangs,
|
||||
};
|
||||
}
|
||||
@@ -151,28 +100,6 @@ export function AboutPage({ version }: AboutPageProps) {
|
||||
};
|
||||
fetchRepoStats();
|
||||
}, []);
|
||||
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 formatTimeAgo = (dateString: string): string => {
|
||||
const now = new Date();
|
||||
const updated = new Date(dateString);
|
||||
@@ -201,74 +128,12 @@ export function AboutPage({ version }: AboutPageProps) {
|
||||
const getLangColor = (lang: string): string => {
|
||||
return langColors[lang] || "#858585";
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
const title = activeTab === "bug_report"
|
||||
? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}`
|
||||
: `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
|
||||
let bodyContent = "";
|
||||
if (activeTab === "bug_report") {
|
||||
const contextContent = bugContext.trim()
|
||||
? bugContext.trim()
|
||||
: "Type here or send screenshot/recording";
|
||||
bodyContent = `### [Bug Report]
|
||||
|
||||
#### Problem
|
||||
${problem || "Type here"}
|
||||
|
||||
#### Type
|
||||
${bugType}
|
||||
|
||||
#### Spotify URL
|
||||
${spotifyUrl || "Type here"}
|
||||
|
||||
#### Additional Context
|
||||
${contextContent}
|
||||
|
||||
#### Environment
|
||||
- SpotiFLAC Version: ${version}
|
||||
- OS: ${os}
|
||||
- Location: ${location}`;
|
||||
}
|
||||
else {
|
||||
const contextContent = featureContext.trim()
|
||||
? featureContext.trim()
|
||||
: "Type here or send screenshot/recording";
|
||||
bodyContent = `### [Feature Request]
|
||||
|
||||
#### Description
|
||||
${featureDesc || "Type here"}
|
||||
|
||||
#### Use Case
|
||||
${useCase || "Type here"}
|
||||
|
||||
#### Additional Context
|
||||
${contextContent}`;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
title: title,
|
||||
body: bodyContent,
|
||||
});
|
||||
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
|
||||
openExternal(url);
|
||||
};
|
||||
return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
|
||||
return (<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
|
||||
<Bug className="h-4 w-4"/>
|
||||
Bug Report
|
||||
</Button>
|
||||
<Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none">
|
||||
<Lightbulb className="h-4 w-4"/>
|
||||
Feature Request
|
||||
</Button>
|
||||
<Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none">
|
||||
<CircleHelp className="h-4 w-4"/>
|
||||
FAQ
|
||||
</Button>
|
||||
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
||||
<Blocks className="h-4 w-4"/>
|
||||
Other Projects
|
||||
@@ -279,100 +144,8 @@ ${contextContent}`;
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
|
||||
{activeTab === "bug_report" && (<div className="flex flex-col">
|
||||
<div className="space-y-4 pt-4 flex flex-col">
|
||||
<div className="mt-4 pr-2">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<Label>Problem</Label>
|
||||
<Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={(e) => setProblem(e.target.value)}/>
|
||||
</div>
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<Label>Additional Context</Label>
|
||||
<DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/>
|
||||
</div>
|
||||
<div className="space-y-4 flex flex-col">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center pt-4 shrink-0">
|
||||
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
||||
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
{activeTab === "feature_request" && (<div className="flex flex-col">
|
||||
<div className="space-y-4 pt-4 flex flex-col">
|
||||
<div className="mt-4 pr-2">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<Label>Description</Label>
|
||||
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={(e) => setFeatureDesc(e.target.value)}/>
|
||||
</div>
|
||||
<div className="space-y-2 flex-col">
|
||||
<Label>Use Case</Label>
|
||||
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={(e) => setUseCase(e.target.value)}/>
|
||||
</div>
|
||||
<div className="space-y-2 flex-col">
|
||||
<Label>Additional Context</Label>
|
||||
<DragDropMedia className="min-h-[14rem]" value={featureContext} onChange={setFeatureContext}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center pt-4 shrink-0">
|
||||
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
||||
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "faq" && (<ScrollArea className="h-full">
|
||||
<div className="p-1 pr-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>
|
||||
</div>
|
||||
</ScrollArea>)}
|
||||
|
||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||
<div className="grid gap-2 grid-cols-4">
|
||||
@@ -402,8 +175,13 @@ ${contextContent}`;
|
||||
</div>
|
||||
<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"/>{" "}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
||||
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiDownloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiDownloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -447,13 +225,17 @@ ${contextContent}`;
|
||||
</Card>
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/>{" "}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Get Spotify tracks in Hi-Res lossless FLACs — no account
|
||||
required.
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-3">
|
||||
@@ -493,8 +275,13 @@ ${contextContent}`;
|
||||
</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"/>{" "}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
Twitter/X Media Batch Downloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -543,22 +330,52 @@ ${contextContent}`;
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-2xl font-bold tracking-tight">Support Me</h3>
|
||||
<p className="text-muted-foreground max-w-[500px]">
|
||||
If this software is useful and brings you value, consider
|
||||
supporting the project on Ko-fi. Your support helps keep
|
||||
development going.
|
||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-32 flex items-center justify-center w-full relative">
|
||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center w-full max-w-lg">
|
||||
<Button size="lg" className="h-16 text-lg font-semibold text-white gap-3 group" style={{ backgroundColor: "#72a4f2" }} onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
|
||||
Support me on Ko-fi
|
||||
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||
Support on Ko-fi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4 w-full">
|
||||
<div className="h-32 flex items-center justify-center">
|
||||
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Crypto donations are also accepted. Scan the QR code or copy the address.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
||||
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||
setCopiedUsdt(true);
|
||||
setTimeout(() => setCopiedUsdt(false), 500);
|
||||
}}>
|
||||
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
interface AlbumInfoProps {
|
||||
albumInfo: {
|
||||
@@ -70,6 +76,65 @@ interface AlbumInfoProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||
const settings = getSettings();
|
||||
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
|
||||
const handleDownloadAlbumCover = async () => {
|
||||
if (!albumInfo.images)
|
||||
return;
|
||||
setDownloadingAlbumCover(true);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const albumName = albumInfo.name;
|
||||
const artistName = albumInfo.artists;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: artistName?.replace(/\//g, placeholder),
|
||||
title: albumName?.replace(/\//g, placeholder),
|
||||
year: albumInfo.release_date?.substring(0, 4),
|
||||
date: albumInfo.release_date,
|
||||
};
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: albumInfo.images,
|
||||
track_name: albumName,
|
||||
artist_name: "",
|
||||
album_name: "",
|
||||
album_artist: "",
|
||||
release_date: "",
|
||||
output_dir: outputDir,
|
||||
filename_format: "title",
|
||||
track_number: false,
|
||||
position: 0,
|
||||
disc_number: 0,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists)
|
||||
toast.info("Cover already exists");
|
||||
else
|
||||
toast.success("Album cover downloaded");
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
}
|
||||
finally {
|
||||
setDownloadingAlbumCover(false);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<Card className="relative">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
@@ -79,7 +144,19 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
</div>)}
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||
{albumInfo.images && (<div className="relative group shrink-0 w-48 h-48">
|
||||
<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadAlbumCover} disabled={downloadingAlbumCover}>
|
||||
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Download Album Cover</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Album</p>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
interface ApiSource {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
const SOURCES: ApiSource[] = [
|
||||
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
||||
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.fun" },
|
||||
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.fun" },
|
||||
];
|
||||
export function ApiStatusTab() {
|
||||
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
||||
const [isCheckingAll, setIsCheckingAll] = useState(false);
|
||||
const checkStatus = async (sourceId: string, apiType: string, url: string) => {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
|
||||
try {
|
||||
const isOnline = await CheckAPIStatus(apiType, url);
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
|
||||
}
|
||||
catch (error) {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
|
||||
}
|
||||
};
|
||||
const checkAll = async () => {
|
||||
setIsCheckingAll(true);
|
||||
const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
|
||||
await Promise.allSettled(promises);
|
||||
setIsCheckingAll(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
checkAll();
|
||||
}, []);
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" onClick={checkAll} disabled={isCheckingAll} className="gap-2">
|
||||
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
||||
Refresh All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{SOURCES.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
|
||||
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
|
||||
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
|
||||
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
|
||||
</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { DragEvent } from "react";
|
||||
import { UploadImageBytes, UploadImage, SelectImageVideo } from "../../wailsjs/go/main/App";
|
||||
import { Upload, Loader2, ImagePlus, X, Check, FileVideo, ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'unknown';
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
interface DragDropMediaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
export function DragDropMedia({ value, onChange, className }: DragDropMediaProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [files, setFiles] = useState<UploadedFile[]>(() => {
|
||||
if (!value)
|
||||
return [];
|
||||
return value.split('\n').filter(line => line.trim()).map((line, i) => {
|
||||
const match = line.match(/!\[(.*?)\]\((.*?)\)/);
|
||||
if (match) {
|
||||
return {
|
||||
id: `init-${i}-${Date.now()}`,
|
||||
name: match[1] === 'image' || match[1] === 'video' ? `file-${i}` : match[1],
|
||||
url: match[2] || line,
|
||||
type: (match[2] && match[2].match(/\.(mp4|mkv|webm|mov)$/i)) ? 'video' : 'image',
|
||||
status: 'done'
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: `init-${i}-${Date.now()}`,
|
||||
name: 'unknown',
|
||||
url: line,
|
||||
type: 'image',
|
||||
status: 'done'
|
||||
};
|
||||
});
|
||||
});
|
||||
useEffect(() => {
|
||||
const newValue = files
|
||||
.filter(f => f.status === 'done' && f.url)
|
||||
.map(f => f.url)
|
||||
.join('\n');
|
||||
if (newValue !== value) {
|
||||
onChange(newValue);
|
||||
}
|
||||
}, [files]);
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
await handleFiles(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
const handleFiles = async (fileList: File[]) => {
|
||||
const timestamp = Date.now();
|
||||
const newFiles: UploadedFile[] = fileList.map((f, i) => ({
|
||||
id: `drop-${timestamp}-${i}`,
|
||||
name: f.name,
|
||||
url: '',
|
||||
type: f.type.startsWith('video') ? 'video' : 'image',
|
||||
status: 'uploading'
|
||||
}));
|
||||
setFiles(prev => [...prev, ...newFiles]);
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i];
|
||||
const fileId = newFiles[i].id;
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const result = await UploadImageBytes(file.name, base64);
|
||||
setFiles(prev => prev.map(f => f.id === fileId
|
||||
? { ...f, status: 'done', url: result }
|
||||
: f));
|
||||
}
|
||||
catch (err: any) {
|
||||
console.error("Upload failed", err);
|
||||
setFiles(prev => prev.map(f => f.id === fileId
|
||||
? { ...f, status: 'error', error: err.message || "Upload failed" }
|
||||
: f));
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const paths = await SelectImageVideo();
|
||||
if (paths && paths.length > 0) {
|
||||
const timestamp = Date.now();
|
||||
const newFiles: UploadedFile[] = paths.map((p, i) => ({
|
||||
id: `select-${timestamp}-${i}`,
|
||||
name: p.split(/[\\/]/).pop() || 'unknown',
|
||||
url: '',
|
||||
type: p.match(/\.(mp4|mkv|webm|mov)$/i) ? 'video' : 'image',
|
||||
status: 'uploading'
|
||||
}));
|
||||
setFiles(prev => [...prev, ...newFiles]);
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const path = paths[i];
|
||||
const fileId = newFiles[i].id;
|
||||
try {
|
||||
const result = await UploadImage(path);
|
||||
setFiles(prev => prev.map(f => f.id === fileId
|
||||
? { ...f, status: 'done', url: result }
|
||||
: f));
|
||||
}
|
||||
catch (err: any) {
|
||||
setFiles(prev => prev.map(f => f.id === fileId
|
||||
? { ...f, status: 'error', error: err.message }
|
||||
: f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err: any) {
|
||||
console.error("Select file failed", err);
|
||||
}
|
||||
};
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
return (<div className={cn("relative group flex flex-col gap-2 p-4 border-2 border-dashed rounded-lg transition-colors border-muted-foreground/25 hover:border-primary/50 min-h-[14rem]", isDragging ? "border-primary bg-primary/10" : "bg-muted/5", className)} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => {
|
||||
if (e.target === e.currentTarget)
|
||||
handleSelectFile();
|
||||
}}>
|
||||
{files.length === 0 && (<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none opacity-50">
|
||||
<ImagePlus className="h-10 w-10 mb-2"/>
|
||||
<span className="text-sm font-medium">Drop media here or click to browse</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">Supports PNG, JPG, MP4, MOV</span>
|
||||
</div>)}
|
||||
|
||||
<div className="flex flex-col gap-2 z-10 w-full">
|
||||
{files.map((file, i) => (<div key={i} className="flex items-center gap-3 p-2 rounded-md bg-background/80 backdrop-blur-sm border shadow-sm animate-in fade-in slide-in-from-bottom-2">
|
||||
{file.type === 'video' ? <FileVideo className="h-8 w-8 text-primary"/> : <ImageIcon className="h-8 w-8 text-primary"/>}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
{file.status === 'uploading' && <span className="text-yellow-500 flex items-center"><Loader2 className="h-3 w-3 animate-spin mr-1"/> Uploading...</span>}
|
||||
{file.status === 'done' && <span className="text-green-500 flex items-center"><Check className="h-3 w-3 mr-1"/> Ready</span>}
|
||||
{file.status === 'error' && <span className="text-red-500">{file.error || 'Failed'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>))}
|
||||
</div>
|
||||
|
||||
|
||||
{isDragging && (<div className="absolute inset-0 flex items-center justify-center bg-background/50 rounded-lg pointer-events-none z-20">
|
||||
<div className="flex flex-col items-center text-primary font-medium">
|
||||
<Upload className="h-10 w-10 mb-2 animate-bounce"/>
|
||||
<span>Drop files to add</span>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
@@ -549,7 +549,7 @@ export function FileManagerPage() {
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
|
||||
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -571,7 +571,7 @@ export function FileManagerPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac</span>
|
||||
</p>
|
||||
</div>)}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
</p>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
@@ -16,8 +16,3 @@ export const AmazonIcon = ({ className = "w-4 h-4" }: {
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
export const DeezerIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 512 512" className={`${className} fill-current`}>
|
||||
<path d="M14.8 101.1C6.6 101.1 0 127.6 0 160.3s6.6 59.2 14.8 59.2s14.8-26.5 14.8-59.2s-6.6-59.2-14.8-59.2m433.9-60.2c-7.7 0-14.5 17.1-19.4 44.1c-7.7-46.7-20.2-77-34.2-77c-16.8 0-31.1 42.9-38 105.4c-6.6-45.4-16.8-74.2-28.3-74.2c-16.1 0-29.6 56.9-34.7 136.2c-9.4-40.8-23.2-66.3-38.3-66.3s-28.8 25.5-38.3 66.3c-5.1-79.3-18.6-136.2-34.7-136.2c-11.5 0-21.7 28.8-28.3 74.2C147.9 50.9 133.3 8 116.7 8c-14 0-26.5 30.4-34.2 77c-4.8-27-11.7-44.1-19.4-44.1c-14.3 0-26 59.2-26 132.1S49 305.2 63.3 305.2c5.9 0 11.5-9.9 15.8-26.8c6.9 61.7 21.2 104.1 38 104.1c13 0 24.5-25.5 32.1-65.6c5.4 76.3 18.6 130.4 34.2 130.4c9.7 0 18.6-21.4 25.3-56.4c7.9 72.2 26.3 122.7 47.7 122.7s39.5-50.5 47.7-122.7c6.6 35 15.6 56.4 25.3 56.4c15.6 0 28.8-54.1 34.2-130.4c7.7 40.1 19.4 65.6 32.1 65.6c16.6 0 30.9-42.3 38-104.1c4.3 16.8 9.7 26.8 15.8 26.8c14.3 0 26-59.2 26-132.1S463 40.9 448.7 40.9m48.5 60.2c-8.2 0-14.8 26.5-14.8 59.2s6.6 59.2 14.8 59.2S512 193 512 160.3s-6.6-59.2-14.8-59.2"/>
|
||||
</svg>);
|
||||
|
||||
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
interface PlaylistInfoProps {
|
||||
playlistInfo: {
|
||||
@@ -81,6 +87,66 @@ interface PlaylistInfoProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||
const settings = getSettings();
|
||||
const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false);
|
||||
const handleDownloadPlaylistCover = async () => {
|
||||
if (!playlistInfo.cover)
|
||||
return;
|
||||
setDownloadingPlaylistCover(true);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const playlistName = playlistInfo.owner.name;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: "",
|
||||
album: "",
|
||||
album_artist: "",
|
||||
title: playlistName.replace(/\//g, placeholder),
|
||||
playlist: playlistName.replace(/\//g, placeholder),
|
||||
};
|
||||
if (settings.createPlaylistFolder && playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: playlistInfo.cover,
|
||||
track_name: playlistName,
|
||||
artist_name: "",
|
||||
album_name: "",
|
||||
album_artist: "",
|
||||
release_date: "",
|
||||
output_dir: outputDir,
|
||||
filename_format: "title",
|
||||
track_number: false,
|
||||
position: 0,
|
||||
disc_number: 0,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists)
|
||||
toast.info("Cover already exists");
|
||||
else
|
||||
toast.success("Playlist cover downloaded");
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
}
|
||||
finally {
|
||||
setDownloadingPlaylistCover(false);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<Card className="relative">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
@@ -90,7 +156,19 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</div>)}
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||
{playlistInfo.cover && (<div className="relative group shrink-0 w-48 h-48">
|
||||
<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadPlaylistCover} disabled={downloadingPlaylistCover}>
|
||||
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Download Playlist Cover</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Playlist</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, } from "lucide-react";
|
||||
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, ArrowUpDown, } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FetchHistory } from "@/components/FetchHistory";
|
||||
@@ -244,6 +245,13 @@ interface SearchBarProps {
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [resultFilter, setResultFilter] = useState("");
|
||||
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
|
||||
tracks: "default",
|
||||
albums: "default",
|
||||
artists: "default",
|
||||
playlists: "default",
|
||||
});
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||
@@ -317,6 +325,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
limit: SEARCH_LIMIT,
|
||||
});
|
||||
setSearchResults(results);
|
||||
setResultFilter("");
|
||||
setLastSearchedQuery(searchQuery.trim());
|
||||
saveRecentSearch(searchQuery.trim());
|
||||
setHasMore({
|
||||
@@ -456,6 +465,88 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
return searchResults.playlists.length;
|
||||
}
|
||||
};
|
||||
const sortedResults = useMemo(() => {
|
||||
if (!searchResults)
|
||||
return { tracks: [], albums: [], artists: [], playlists: [] };
|
||||
const filterStr = resultFilter.toLowerCase();
|
||||
let tracks = [...searchResults.tracks];
|
||||
if (filterStr) {
|
||||
tracks = tracks.filter(t => (t.name || '').toLowerCase().includes(filterStr) || (t.artists || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const tSort = sortOrders.tracks;
|
||||
if (tSort !== 'default') {
|
||||
tracks.sort((a, b) => {
|
||||
if (tSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (tSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (tSort === 'artist-asc')
|
||||
return (a.artists || '').localeCompare(b.artists || '');
|
||||
if (tSort === 'artist-desc')
|
||||
return (b.artists || '').localeCompare(a.artists || '');
|
||||
if (tSort === 'duration-desc')
|
||||
return (b.duration_ms || 0) - (a.duration_ms || 0);
|
||||
if (tSort === 'duration-asc')
|
||||
return (a.duration_ms || 0) - (b.duration_ms || 0);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let albums = [...searchResults.albums];
|
||||
if (filterStr) {
|
||||
albums = albums.filter(a => (a.name || '').toLowerCase().includes(filterStr) || (a.artists || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const alSort = sortOrders.albums;
|
||||
if (alSort !== 'default') {
|
||||
albums.sort((a, b) => {
|
||||
if (alSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (alSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (alSort === 'artist-asc')
|
||||
return (a.artists || '').localeCompare(b.artists || '');
|
||||
if (alSort === 'artist-desc')
|
||||
return (b.artists || '').localeCompare(a.artists || '');
|
||||
if (alSort === 'year-desc')
|
||||
return (b.release_date || '').localeCompare(a.release_date || '');
|
||||
if (alSort === 'year-asc')
|
||||
return (a.release_date || '').localeCompare(b.release_date || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let artists = [...searchResults.artists];
|
||||
if (filterStr) {
|
||||
artists = artists.filter(a => (a.name || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const arSort = sortOrders.artists;
|
||||
if (arSort !== 'default') {
|
||||
artists.sort((a, b) => {
|
||||
if (arSort === 'name-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (arSort === 'name-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let playlists = [...searchResults.playlists];
|
||||
if (filterStr) {
|
||||
playlists = playlists.filter(p => (p.name || '').toLowerCase().includes(filterStr) || (p.owner || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const pSort = sortOrders.playlists;
|
||||
if (pSort !== 'default') {
|
||||
playlists.sort((a, b) => {
|
||||
if (pSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (pSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (pSort === 'owner-asc')
|
||||
return (a.owner || '').localeCompare(b.owner || '');
|
||||
if (pSort === 'owner-desc')
|
||||
return (b.owner || '').localeCompare(a.owner || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return { tracks, albums, artists, playlists };
|
||||
}, [searchResults, sortOrders, resultFilter]);
|
||||
const tabs: {
|
||||
key: ResultTab;
|
||||
label: string;
|
||||
@@ -490,6 +581,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
setSearchQuery("");
|
||||
setSearchResults(null);
|
||||
setLastSearchedQuery("");
|
||||
setResultFilter("");
|
||||
}}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
@@ -550,7 +642,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</div>)}
|
||||
|
||||
{!isSearching && hasAnyResults && (<>
|
||||
<div className="flex gap-1 border-b">
|
||||
<div className="flex gap-1 border-b mb-4">
|
||||
{tabs.map((tab) => {
|
||||
const count = getTabCount(tab.key);
|
||||
if (count === 0)
|
||||
@@ -563,9 +655,54 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input placeholder={`Search ${activeTab}...`} value={resultFilter} onChange={(e) => setResultFilter(e.target.value)} className="pl-10 pr-8"/>
|
||||
{resultFilter && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => setResultFilter("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</div>
|
||||
<Select value={sortOrders[activeTab]} onValueChange={(val) => setSortOrders(prev => ({ ...prev, [activeTab]: val }))}>
|
||||
<SelectTrigger className="w-[170px] bg-background gap-1.5">
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground"/>
|
||||
<SelectValue placeholder="Sort by"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
{activeTab === 'tracks' && (<>
|
||||
<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-desc">Duration (Longest)</SelectItem>
|
||||
<SelectItem value="duration-asc">Duration (Shortest)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'albums' && (<>
|
||||
<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="year-desc">Year (Newest)</SelectItem>
|
||||
<SelectItem value="year-asc">Year (Oldest)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'artists' && (<>
|
||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'playlists' && (<>
|
||||
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="owner-asc">Owner (A-Z)</SelectItem>
|
||||
<SelectItem value="owner-desc">Owner (Z-A)</SelectItem>
|
||||
</>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{activeTab === "tracks" &&
|
||||
searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||
sortedResults.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
@@ -584,7 +721,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</button>))}
|
||||
|
||||
{activeTab === "albums" &&
|
||||
searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||
sortedResults.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{album.name}</p>
|
||||
@@ -598,7 +735,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</button>))}
|
||||
|
||||
{activeTab === "artists" &&
|
||||
searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||
sortedResults.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{artist.name}</p>
|
||||
@@ -607,7 +744,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</button>))}
|
||||
|
||||
{activeTab === "playlists" &&
|
||||
searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||
sortedResults.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{playlist.name}</p>
|
||||
|
||||
@@ -5,13 +5,14 @@ import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, Settings, FolderCog, } from "lucide-react";
|
||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { SelectFolder } from "../../wailsjs/go/main/App";
|
||||
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { ApiStatusTab } from "./ApiStatusTab";
|
||||
const TidalIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
@@ -30,11 +31,6 @@ const AmazonIcon = ({ className }: {
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
const DeezerIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 512 512" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
<path fill="currentColor" d="M14.8 101.1C6.6 101.1 0 127.6 0 160.3s6.6 59.2 14.8 59.2s14.8-26.5 14.8-59.2s-6.6-59.2-14.8-59.2m433.9-60.2c-7.7 0-14.5 17.1-19.4 44.1c-7.7-46.7-20.2-77-34.2-77c-16.8 0-31.1 42.9-38 105.4c-6.6-45.4-16.8-74.2-28.3-74.2c-16.1 0-29.6 56.9-34.7 136.2c-9.4-40.8-23.2-66.3-38.3-66.3s-28.8 25.5-38.3 66.3c-5.1-79.3-18.6-136.2-34.7-136.2c-11.5 0-21.7 28.8-28.3 74.2C147.9 50.9 133.3 8 116.7 8c-14 0-26.5 30.4-34.2 77c-4.8-27-11.7-44.1-19.4-44.1c-14.3 0-26 59.2-26 132.1S49 305.2 63.3 305.2c5.9 0 11.5-9.9 15.8-26.8c6.9 61.7 21.2 104.1 38 104.1c13 0 24.5-25.5 32.1-65.6c5.4 76.3 18.6 130.4 34.2 130.4c9.7 0 18.6-21.4 25.3-56.4c7.9 72.2 26.3 122.7 47.7 122.7s39.5-50.5 47.7-122.7c6.6 35 15.6 56.4 25.3 56.4c15.6 0 28.8-54.1 34.2-130.4c7.7 40.1 19.4 65.6 32.1 65.6c16.6 0 30.9-42.3 38-104.1c4.3 16.8 9.7 26.8 15.8 26.8c14.3 0 26-59.2 26-132.1S463 40.9 448.7 40.9m48.5 60.2c-8.2 0-14.8 26.5-14.8 59.2s6.6 59.2 14.8 59.2S512 193 512 160.3s-6.6-59.2-14.8-59.2"/>
|
||||
</svg>);
|
||||
interface SettingsPageProps {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
@@ -129,11 +125,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState<"general" | "files">("general");
|
||||
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
||||
return (<div className="space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={async () => { try {
|
||||
await OpenConfigFolder();
|
||||
}
|
||||
catch (e) {
|
||||
toast.error(`Failed to open config folder: ${e}`);
|
||||
} }} className="gap-1.5">
|
||||
<FolderLock className="h-4 w-4"/>
|
||||
Open Config Folder
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||
<RotateCcw className="h-4 w-4"/>
|
||||
Reset to Default
|
||||
@@ -147,13 +152,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
|
||||
<Settings className="h-4 w-4"/>
|
||||
<MonitorCog className="h-4 w-4"/>
|
||||
General
|
||||
</Button>
|
||||
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||
<FolderCog className="h-4 w-4"/>
|
||||
File Management
|
||||
</Button>
|
||||
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
||||
<Router className="h-4 w-4"/>
|
||||
API Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
@@ -266,12 +275,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
Amazon Music
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer">
|
||||
<span className="flex items-center">
|
||||
<DeezerIcon />
|
||||
Deezer
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -285,139 +289,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
<SelectItem value="tidal-qobuz-amazon-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="tidal-qobuz-deezer-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-tidal-amazon-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-tidal-qobuz-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer-tidal-qobuz-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer-qobuz-amazon-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer-amazon-tidal-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
|
||||
<SelectItem value="tidal-qobuz-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="tidal-amazon-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-amazon-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-qobuz-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-tidal-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer-qobuz-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="tidal-qobuz-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
@@ -427,50 +298,53 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
|
||||
<SelectItem value="tidal-deezer">
|
||||
<SelectItem value="tidal-amazon-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-deezer">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<DeezerIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer-amazon">
|
||||
<SelectItem value="qobuz-tidal-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DeezerIcon className="fill-current"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-amazon-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-tidal-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-qobuz-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
|
||||
<SelectItem value="tidal-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
@@ -552,9 +426,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
{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">
|
||||
16-bit - 24-bit/44.1kHz - 192kHz
|
||||
</div>)}
|
||||
{tempSettings.downloader === "deezer" && (<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
|
||||
</div>)}
|
||||
|
||||
</div>
|
||||
|
||||
{((tempSettings.downloader === "tidal" &&
|
||||
@@ -664,9 +536,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
Preview:{" "}
|
||||
<span className="font-mono">
|
||||
{tempSettings.folderTemplate
|
||||
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
||||
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||
.replace(/\{album\}/g, "Black Panther")
|
||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||
.replace(/\{title\}/g, "All The Stars")
|
||||
.replace(/\{track\}/g, "01")
|
||||
.replace(/\{disc\}/g, "1")
|
||||
.replace(/\{year\}/g, "2018")
|
||||
.replace(/\{date\}/g, "2018-02-09")}
|
||||
/
|
||||
@@ -747,12 +622,31 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
filenameTemplate: e.target.value,
|
||||
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||
</div>
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-sm">Separator</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
separator: value,
|
||||
}))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="comma">Comma (,)</SelectItem>
|
||||
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||
Preview:{" "}
|
||||
<span className="font-mono">
|
||||
{tempSettings.filenameTemplate
|
||||
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
||||
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||
.replace(/\{album\}/g, "Black Panther")
|
||||
.replace(/\{title\}/g, "All The Stars")
|
||||
.replace(/\{track\}/g, "01")
|
||||
.replace(/\{disc\}/g, "1")
|
||||
@@ -761,8 +655,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
.flac
|
||||
</span>
|
||||
</p>)}
|
||||
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "api" && (<ApiStatusTab />)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { FileMusicIcon } from "@/components/ui/file-music";
|
||||
import { FilePenIcon } from "@/components/ui/file-pen";
|
||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
@@ -42,34 +45,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<ActivityIcon size={20}/>
|
||||
<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>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<FileMusicIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Converter</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<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")}>
|
||||
<FilePenIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>File Manager</p>
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -84,19 +65,48 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={0}>
|
||||
<DropdownMenuTrigger 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")}>
|
||||
<SettingsIcon size={20}/>
|
||||
<Button variant={["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||
<BlocksIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
<p>Tools</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3">
|
||||
<ActivityIcon size={16}/>
|
||||
<span>Audio Quality Analyzer</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3">
|
||||
<FileMusicIcon size={16}/>
|
||||
<span>Audio Converter</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3">
|
||||
<FilePenIcon size={16}/>
|
||||
<span>File Manager</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<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://github.com/afkarxyz/SpotiFLAC/issues/268")}>
|
||||
<GithubIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bugs or Request Features</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<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")}>
|
||||
@@ -107,6 +117,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<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://ko-fi.com/afkarxyz")}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { X, Minus, Maximize, Settings, Info } from "lucide-react";
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -43,7 +43,7 @@ export function TitleBar() {
|
||||
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||
<Settings className="w-3.5 h-3.5"/>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||
</MenubarTrigger>
|
||||
<MenubarContent align="end" className="min-w-[200px]">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & {
|
||||
@@ -119,7 +119,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
<p>Download Separate Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
@@ -129,7 +129,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
<p>Download Separate Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
@@ -143,7 +143,6 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, DeezerIcon } from "./PlatformIcons";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
@@ -304,7 +304,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
<p>Download Separate Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
@@ -317,7 +317,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
<p>Download Separate Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
@@ -331,7 +331,6 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"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 BlocksIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
const VARIANTS: Variants = {
|
||||
normal: { translateX: 0, translateY: 0 },
|
||||
animate: { translateX: -4, translateY: 4 },
|
||||
};
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...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("flex items-center justify-center", 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">
|
||||
<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 animate={controls} d="M14 3h7v7h-7z" variants={VARIANTS}/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
BlocksIcon.displayName = "BlocksIcon";
|
||||
export { BlocksIcon };
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props}/>;
|
||||
}
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props}/>);
|
||||
}
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}/>);
|
||||
}
|
||||
function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>
|
||||
</DropdownMenuPrimitive.Portal>);
|
||||
}
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props}/>);
|
||||
}
|
||||
function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn("relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (<DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>);
|
||||
}
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props}/>);
|
||||
}
|
||||
function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (<DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>);
|
||||
}
|
||||
function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (<DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (<span data-slot="dropdown-menu-shortcut" className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props}/>;
|
||||
}
|
||||
function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn("flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className)} {...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4"/>
|
||||
</DropdownMenuPrimitive.SubTrigger>);
|
||||
}
|
||||
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (<DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn("z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>);
|
||||
}
|
||||
export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
|
||||
@@ -0,0 +1,102 @@
|
||||
"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 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: Number.POSITIVE_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) {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
else {
|
||||
bodyControls.start("animate");
|
||||
await tailControls.start("draw");
|
||||
tailControls.start("wag");
|
||||
}
|
||||
}, [bodyControls, onMouseEnter, tailControls]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
else {
|
||||
bodyControls.start("normal");
|
||||
tailControls.start("normal");
|
||||
}
|
||||
}, [bodyControls, tailControls, 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.path 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" initial="normal" variants={BODY_VARIANTS}/>
|
||||
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
GithubIcon.displayName = "GithubIcon";
|
||||
export { GithubIcon };
|
||||
@@ -43,7 +43,7 @@ export function useCover() {
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -145,7 +145,7 @@ export function useCover() {
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
|
||||
@@ -306,54 +306,6 @@ export function useDownload(region: string) {
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
else if (s === "deezer") {
|
||||
try {
|
||||
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "deezer",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
album_name: albumName,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: finalReleaseDate || releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: "flac",
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
spotify_total_discs: spotifyTotalDiscs,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`deezer: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Deezer] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`deezer failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`deezer error: ${err}`);
|
||||
fallbackErrors.push(`[Deezer] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (itemID) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
@@ -375,7 +327,7 @@ export function useDownload(region: string) {
|
||||
}
|
||||
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
service: service as "tidal" | "qobuz" | "amazon" | "deezer",
|
||||
service: service as "tidal" | "qobuz" | "amazon",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
@@ -632,54 +584,6 @@ export function useDownload(region: string) {
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
else if (s === "deezer") {
|
||||
try {
|
||||
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "deezer",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
album_name: albumName,
|
||||
album_artist: displayAlbumArtist,
|
||||
release_date: finalReleaseDate || releaseDate,
|
||||
cover_url: coverUrl,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position: trackNumberForTemplate,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: "flac",
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
spotify_total_discs: spotifyTotalDiscs,
|
||||
copyright: copyright,
|
||||
publisher: publisher,
|
||||
use_first_artist_only: settings.useFirstArtistOnly,
|
||||
use_single_genre: settings.useSingleGenre,
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`deezer: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[Deezer] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`deezer failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`deezer error: ${err}`);
|
||||
fallbackErrors.push(`[Deezer] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!lastResponse.success && itemID) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
@@ -696,11 +600,8 @@ export function useDownload(region: string) {
|
||||
else if (service === "qobuz") {
|
||||
audioFormat = settings.qobuzQuality || "6";
|
||||
}
|
||||
else if (service === "deezer") {
|
||||
audioFormat = "flac";
|
||||
}
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
service: service as "tidal" | "qobuz" | "amazon" | "deezer",
|
||||
service: service as "tidal" | "qobuz" | "amazon",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: displayArtist,
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useLyrics() {
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
@@ -141,7 +141,7 @@ export function useLyrics() {
|
||||
};
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
|
||||
@@ -4,7 +4,7 @@ export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-ar
|
||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon" | "deezer";
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
@@ -23,7 +23,7 @@ export interface Settings {
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
amazonQuality: "original";
|
||||
autoOrder: "tidal-qobuz-amazon-deezer" | "tidal-qobuz-deezer-amazon" | "tidal-amazon-qobuz-deezer" | "tidal-amazon-deezer-qobuz" | "tidal-deezer-qobuz-amazon" | "tidal-deezer-amazon-qobuz" | "qobuz-tidal-amazon-deezer" | "qobuz-tidal-deezer-amazon" | "qobuz-amazon-tidal-deezer" | "qobuz-amazon-deezer-tidal" | "qobuz-deezer-tidal-amazon" | "qobuz-deezer-amazon-tidal" | "amazon-tidal-qobuz-deezer" | "amazon-tidal-deezer-qobuz" | "amazon-qobuz-tidal-deezer" | "amazon-qobuz-deezer-tidal" | "amazon-deezer-tidal-qobuz" | "amazon-deezer-qobuz-tidal" | "deezer-tidal-qobuz-amazon" | "deezer-tidal-amazon-qobuz" | "deezer-qobuz-tidal-amazon" | "deezer-qobuz-amazon-tidal" | "deezer-amazon-tidal-qobuz" | "deezer-amazon-qobuz-tidal" | string;
|
||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
|
||||
autoQuality: "16" | "24";
|
||||
allowFallback: boolean;
|
||||
useSpotFetchAPI: boolean;
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
useFirstArtistOnly: boolean;
|
||||
useSingleGenre: boolean;
|
||||
embedGenre: boolean;
|
||||
separator: "comma" | "semicolon";
|
||||
}
|
||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||
label: string;
|
||||
@@ -107,7 +108,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "original",
|
||||
autoOrder: "tidal-qobuz-amazon-deezer",
|
||||
autoOrder: "tidal-qobuz-amazon",
|
||||
autoQuality: "16",
|
||||
allowFallback: true,
|
||||
useSpotFetchAPI: false,
|
||||
@@ -116,7 +117,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
createM3u8File: false,
|
||||
useFirstArtistOnly: false,
|
||||
useSingleGenre: false,
|
||||
embedGenre: true
|
||||
embedGenre: true,
|
||||
separator: "semicolon"
|
||||
};
|
||||
export const FONT_OPTIONS: {
|
||||
value: FontFamily;
|
||||
@@ -223,6 +225,9 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
}
|
||||
@@ -314,6 +319,9 @@ export async function loadSettings(): Promise<Settings> {
|
||||
if (!('embedGenre' in parsed)) {
|
||||
parsed.embedGenre = true;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return cachedSettings!;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface ArtistResponse {
|
||||
}
|
||||
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
|
||||
export interface DownloadRequest {
|
||||
service: "tidal" | "qobuz" | "amazon" | "deezer";
|
||||
service: "tidal" | "qobuz" | "amazon";
|
||||
query?: string;
|
||||
track_name?: string;
|
||||
artist_name?: string;
|
||||
@@ -204,11 +204,9 @@ export interface TrackAvailability {
|
||||
tidal: boolean;
|
||||
amazon: boolean;
|
||||
qobuz: boolean;
|
||||
deezer: boolean;
|
||||
tidal_url?: string;
|
||||
amazon_url?: string;
|
||||
qobuz_url?: string;
|
||||
deezer_url?: string;
|
||||
}
|
||||
export interface CoverDownloadRequest {
|
||||
cover_url: string;
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "7.1.0",
|
||||
"productVersion": "7.1.1",
|
||||
"copyright": "© 2026 afkarxyz"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
|
||||
Reference in New Issue
Block a user