v7.0.1
This commit is contained in:
@@ -1,39 +0,0 @@
|
||||
# Development Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running the application locally, ensure you have the following installed:
|
||||
|
||||
1. **Go** (v1.23+ recommended)
|
||||
2. **Node.js** (v16+ recommended)
|
||||
3. **Wails CLI**
|
||||
|
||||
### Installing Wails
|
||||
|
||||
Since you already have Go installed, you can install the Wails CLI by running:
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
```
|
||||
|
||||
Ensure that your `go/bin` directory is in your PATH. You can check if it's installed by running `wails version`.
|
||||
|
||||
## Running the Application
|
||||
|
||||
To run the application in development mode (with hot reloading for both frontend and backend):
|
||||
|
||||
```bash
|
||||
wails dev
|
||||
```
|
||||
|
||||
This will compiles the application and open it in a window. It also starts a browser-based version at http://localhost:34115.
|
||||
|
||||
## Building for Production
|
||||
|
||||
To create a production build (Application.app):
|
||||
|
||||
```bash
|
||||
wails build
|
||||
```
|
||||
|
||||
The output will be in the `build/bin` directory.
|
||||
@@ -12,23 +12,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
// SpotifyMetadataRequest represents the request structure for fetching Spotify metadata
|
||||
type SpotifyMetadataRequest struct {
|
||||
URL string `json:"url"`
|
||||
Batch bool `json:"batch"`
|
||||
@@ -36,7 +31,6 @@ type SpotifyMetadataRequest struct {
|
||||
Timeout float64 `json:"timeout"`
|
||||
}
|
||||
|
||||
// DownloadRequest represents the request structure for downloading tracks
|
||||
type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
@@ -46,36 +40,37 @@ type DownloadRequest struct {
|
||||
AlbumName string `json:"album_name,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"` // Spotify cover URL for embedding
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ApiURL string `json:"api_url,omitempty"`
|
||||
OutputDir string `json:"output_dir,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
FilenameFormat string `json:"filename_format,omitempty"`
|
||||
TrackNumber bool `json:"track_number,omitempty"`
|
||||
Position int `json:"position,omitempty"` // Position in playlist/album (1-based)
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
|
||||
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
||||
EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` // Whether to embed max quality cover art
|
||||
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
||||
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
|
||||
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
|
||||
SpotifyTrackNumber int `json:"spotify_track_number,omitempty"` // Track number from Spotify album
|
||||
SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"` // Disc number from Spotify album
|
||||
SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"` // Total tracks in album from Spotify
|
||||
Position int `json:"position,omitempty"`
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
EmbedLyrics bool `json:"embed_lyrics,omitempty"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"`
|
||||
ServiceURL string `json:"service_url,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
SpotifyTrackNumber int `json:"spotify_track_number,omitempty"`
|
||||
SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"`
|
||||
SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"`
|
||||
SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResponse represents the response structure for download operations
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"` // Queue item ID for tracking
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
}
|
||||
|
||||
// GetStreamingURLs fetches all streaming URLs from song.link API
|
||||
func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return "", fmt.Errorf("spotify track ID is required")
|
||||
@@ -96,7 +91,6 @@ func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) {
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify
|
||||
func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
||||
if req.URL == "" {
|
||||
return "", fmt.Errorf("URL parameter is required")
|
||||
@@ -125,13 +119,11 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// SpotifySearchRequest represents the request structure for searching Spotify
|
||||
type SpotifySearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// SearchSpotify searches for tracks, albums, artists, and playlists on Spotify
|
||||
func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, error) {
|
||||
if req.Query == "" {
|
||||
return nil, fmt.Errorf("search query is required")
|
||||
@@ -147,15 +139,13 @@ func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse,
|
||||
return backend.SearchSpotify(ctx, req.Query, req.Limit)
|
||||
}
|
||||
|
||||
// SpotifySearchByTypeRequest represents the request for searching by specific type with offset
|
||||
type SpotifySearchByTypeRequest struct {
|
||||
Query string `json:"query"`
|
||||
SearchType string `json:"search_type"` // track, album, artist, playlist
|
||||
SearchType string `json:"search_type"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// SearchSpotifyByType searches for a specific type with offset support for pagination
|
||||
func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.SearchResult, error) {
|
||||
if req.Query == "" {
|
||||
return nil, fmt.Errorf("search query is required")
|
||||
@@ -175,13 +165,13 @@ func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.Sea
|
||||
return backend.SearchSpotifyByType(ctx, req.Query, req.SearchType, req.Limit, req.Offset)
|
||||
}
|
||||
|
||||
// DownloadTrack downloads a track by ISRC
|
||||
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if req.ISRC == "" {
|
||||
|
||||
if req.Service == "qobuz" && req.ISRC == "" && req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "ISRC is required",
|
||||
}, fmt.Errorf("ISRC is required")
|
||||
Error: "Spotify ID is required for Qobuz",
|
||||
}, fmt.Errorf("spotify ID is required for Qobuz")
|
||||
}
|
||||
|
||||
if req.Service == "" {
|
||||
@@ -191,7 +181,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if req.OutputDir == "" {
|
||||
req.OutputDir = "."
|
||||
} else {
|
||||
// Only normalize path separators, don't sanitize user's existing folder names
|
||||
|
||||
req.OutputDir = backend.NormalizePath(req.OutputDir)
|
||||
}
|
||||
|
||||
@@ -202,62 +192,89 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
var err error
|
||||
var filename string
|
||||
|
||||
// Set default filename format if not provided
|
||||
if req.FilenameFormat == "" {
|
||||
req.FilenameFormat = "title-artist"
|
||||
}
|
||||
|
||||
// ItemID should always be provided by frontend (created via AddToDownloadQueue)
|
||||
// If not provided, generate one for backwards compatibility
|
||||
itemID := req.ItemID
|
||||
if itemID == "" {
|
||||
itemID = fmt.Sprintf("%s-%d", req.ISRC, time.Now().UnixNano())
|
||||
// Add to queue if no ItemID was provided (legacy support)
|
||||
backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.ISRC)
|
||||
|
||||
if req.SpotifyID != "" {
|
||||
itemID = fmt.Sprintf("%s-%d", req.SpotifyID, time.Now().UnixNano())
|
||||
} else {
|
||||
itemID = fmt.Sprintf("%s-%s-%d", req.TrackName, req.ArtistName, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID)
|
||||
}
|
||||
|
||||
// Mark item as downloading immediately
|
||||
backend.SetDownloading(true)
|
||||
backend.StartDownloadItem(itemID)
|
||||
defer backend.SetDownloading(false)
|
||||
|
||||
// Early check: Check if file with same ISRC already exists
|
||||
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
|
||||
backend.SkipDownloadItem(itemID, existingFile)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File with same ISRC already exists",
|
||||
File: existingFile,
|
||||
AlreadyExists: true,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
spotifyURL := ""
|
||||
if req.SpotifyID != "" {
|
||||
spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
||||
}
|
||||
|
||||
if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
||||
trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0)
|
||||
if err == nil {
|
||||
|
||||
var trackResp struct {
|
||||
Track struct {
|
||||
Copyright string `json:"copyright"`
|
||||
Publisher string `json:"publisher"`
|
||||
TotalDiscs int `json:"total_discs"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
} `json:"track"`
|
||||
}
|
||||
if jsonData, jsonErr := json.Marshal(trackData); jsonErr == nil {
|
||||
if json.Unmarshal(jsonData, &trackResp) == nil {
|
||||
|
||||
if req.Copyright == "" && trackResp.Track.Copyright != "" {
|
||||
req.Copyright = trackResp.Track.Copyright
|
||||
}
|
||||
if req.Publisher == "" && trackResp.Track.Publisher != "" {
|
||||
req.Publisher = trackResp.Track.Publisher
|
||||
}
|
||||
if req.SpotifyTotalDiscs == 0 && trackResp.Track.TotalDiscs > 0 {
|
||||
req.SpotifyTotalDiscs = trackResp.Track.TotalDiscs
|
||||
}
|
||||
if req.SpotifyTotalTracks == 0 && trackResp.Track.TotalTracks > 0 {
|
||||
req.SpotifyTotalTracks = trackResp.Track.TotalTracks
|
||||
}
|
||||
if req.SpotifyTrackNumber == 0 && trackResp.Track.TrackNumber > 0 {
|
||||
req.SpotifyTrackNumber = trackResp.Track.TrackNumber
|
||||
}
|
||||
if req.ReleaseDate == "" && trackResp.Track.ReleaseDate != "" {
|
||||
req.ReleaseDate = trackResp.Track.ReleaseDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we have track metadata, check if file already exists by filename
|
||||
if req.TrackName != "" && req.ArtistName != "" {
|
||||
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
|
||||
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||
// Validate the file by checking if it has valid ISRC metadata
|
||||
if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
|
||||
// File exists and has valid metadata - skip download
|
||||
backend.SkipDownloadItem(itemID, expectedPath)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
File: expectedPath,
|
||||
AlreadyExists: true,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
} else {
|
||||
// File exists but has no valid ISRC metadata - it's corrupted, delete it
|
||||
fmt.Printf("Removing corrupted file (no valid ISRC metadata): %s\n", expectedPath)
|
||||
if removeErr := os.Remove(expectedPath); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", expectedPath, removeErr)
|
||||
}
|
||||
}
|
||||
|
||||
backend.SkipDownloadItem(itemID, expectedPath)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
File: expectedPath,
|
||||
AlreadyExists: true,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,8 +282,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
case "amazon":
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
|
||||
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, 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)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -274,15 +291,15 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "Spotify ID is required for Amazon Music",
|
||||
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
||||
}
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, 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)
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly with fallback to multiple APIs
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, 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.ISRC)
|
||||
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, 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)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -290,14 +307,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "Spotify ID is required for Tidal",
|
||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||
}
|
||||
// Use ISRC matching for search fallback
|
||||
filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, 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)
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly with specific API
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, 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.ISRC)
|
||||
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, 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)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
@@ -305,19 +322,45 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Error: "Spotify ID is required for Tidal",
|
||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||
}
|
||||
// Use ISRC matching for search fallback
|
||||
filename, err = downloader.DownloadWithISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
|
||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, 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)
|
||||
}
|
||||
}
|
||||
|
||||
case "qobuz":
|
||||
downloader := backend.NewQobuzDownloader()
|
||||
// Default to "6" (FLAC 16-bit) for Qobuz if not specified
|
||||
|
||||
quality := req.AudioFormat
|
||||
if quality == "" {
|
||||
quality = "6"
|
||||
}
|
||||
filename, err = downloader.DownloadByISRC(req.ISRC, 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)
|
||||
|
||||
deezerISRC := req.ISRC
|
||||
if deezerISRC == "" && req.SpotifyID != "" {
|
||||
|
||||
songlinkClient := backend.NewSongLinkClient()
|
||||
deezerURL, err := songlinkClient.GetDeezerURLFromSpotify(req.SpotifyID)
|
||||
if err != nil {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to get Deezer URL: %v", err),
|
||||
}, err
|
||||
}
|
||||
deezerISRC, err = backend.GetDeezerISRC(deezerURL)
|
||||
if err != nil {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to get ISRC from Deezer: %v", err),
|
||||
}, err
|
||||
}
|
||||
}
|
||||
if deezerISRC == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "ISRC is required for Qobuz (could not fetch from Deezer)",
|
||||
}, fmt.Errorf("ISRC is required for Qobuz")
|
||||
}
|
||||
filename, err = downloader.DownloadByISRC(deezerISRC, 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)
|
||||
|
||||
default:
|
||||
return DownloadResponse{
|
||||
@@ -327,9 +370,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Clean up any partial/corrupted file that was created during failed download
|
||||
|
||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||
// Check if file exists and delete it
|
||||
|
||||
if _, statErr := os.Stat(filename); statErr == nil {
|
||||
fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename)
|
||||
if removeErr := os.Remove(filename); removeErr != nil {
|
||||
@@ -338,8 +381,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Don't mark as failed in backend - let the frontend handle it
|
||||
// Frontend will call MarkDownloadItemFailed after all services are tried
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Download failed: %v", err),
|
||||
@@ -347,14 +388,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
// Check if file already existed
|
||||
alreadyExists := false
|
||||
if strings.HasPrefix(filename, "EXISTS:") {
|
||||
alreadyExists = true
|
||||
filename = strings.TrimPrefix(filename, "EXISTS:")
|
||||
}
|
||||
|
||||
// Embed lyrics after successful download (only for new downloads with Spotify ID and if embedLyrics is enabled)
|
||||
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") {
|
||||
go func(filePath, spotifyID, trackName, artistName string) {
|
||||
fmt.Printf("\n========== LYRICS FETCH START ==========\n")
|
||||
@@ -365,8 +404,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
|
||||
lyricsClient := backend.NewLyricsClient()
|
||||
|
||||
// Try all sources with fallbacks
|
||||
lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||
lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName, 0)
|
||||
if err != nil {
|
||||
fmt.Printf("All sources failed: %v\n", err)
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
@@ -390,7 +428,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Show full lyrics in console for debugging
|
||||
fmt.Printf("\n--- Full LRC Content ---\n")
|
||||
fmt.Println(lyrics)
|
||||
fmt.Printf("--- End LRC Content ---\n\n")
|
||||
@@ -411,12 +448,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
message = "File already exists"
|
||||
backend.SkipDownloadItem(itemID, filename)
|
||||
} else {
|
||||
// Get file size for completed download
|
||||
|
||||
if fileInfo, statErr := os.Stat(filename); statErr == nil {
|
||||
finalSize := float64(fileInfo.Size()) / (1024 * 1024) // Convert to MB
|
||||
finalSize := float64(fileInfo.Size()) / (1024 * 1024)
|
||||
backend.CompleteDownloadItem(itemID, filename, finalSize)
|
||||
} else {
|
||||
// Fallback: mark as completed without size
|
||||
|
||||
backend.CompleteDownloadItem(itemID, filename, 0)
|
||||
}
|
||||
}
|
||||
@@ -430,7 +467,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenFolder opens a folder in the file explorer
|
||||
func (a *App) OpenFolder(path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("path is required")
|
||||
@@ -444,67 +480,55 @@ func (a *App) OpenFolder(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectFolder opens a folder selection dialog and returns the selected path
|
||||
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
||||
}
|
||||
|
||||
// SelectFile opens a file selection dialog and returns the selected file path
|
||||
func (a *App) SelectFile() (string, error) {
|
||||
return backend.SelectFileDialog(a.ctx)
|
||||
}
|
||||
|
||||
// GetDefaults returns the default configuration
|
||||
func (a *App) GetDefaults() map[string]string {
|
||||
return map[string]string{
|
||||
"downloadPath": backend.GetDefaultMusicPath(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetDownloadProgress returns current download progress
|
||||
func (a *App) GetDownloadProgress() backend.ProgressInfo {
|
||||
return backend.GetDownloadProgress()
|
||||
}
|
||||
|
||||
// GetDownloadQueue returns the complete download queue state
|
||||
func (a *App) GetDownloadQueue() backend.DownloadQueueInfo {
|
||||
return backend.GetDownloadQueue()
|
||||
}
|
||||
|
||||
// ClearCompletedDownloads clears completed, failed, and skipped items from the queue
|
||||
func (a *App) ClearCompletedDownloads() {
|
||||
backend.ClearDownloadQueue()
|
||||
}
|
||||
|
||||
// ClearAllDownloads clears the entire queue and resets session stats
|
||||
func (a *App) ClearAllDownloads() {
|
||||
backend.ClearAllDownloads()
|
||||
}
|
||||
|
||||
// AddToDownloadQueue adds a new item to the download queue and returns its ID
|
||||
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
|
||||
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
|
||||
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
|
||||
return itemID
|
||||
}
|
||||
|
||||
// MarkDownloadItemFailed marks a download item as failed
|
||||
func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) {
|
||||
backend.FailDownloadItem(itemID, errorMsg)
|
||||
}
|
||||
|
||||
// CancelAllQueuedItems marks all queued items as cancelled/skipped
|
||||
func (a *App) CancelAllQueuedItems() {
|
||||
backend.CancelAllQueuedItems()
|
||||
}
|
||||
|
||||
// Quit closes the application
|
||||
func (a *App) Quit() {
|
||||
// You can add cleanup logic here if needed
|
||||
panic("quit") // This will trigger Wails to close the app
|
||||
|
||||
panic("quit")
|
||||
}
|
||||
|
||||
// AnalyzeTrack analyzes audio quality of a FLAC file
|
||||
func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
||||
if filePath == "" {
|
||||
return "", fmt.Errorf("file path is required")
|
||||
@@ -523,7 +547,6 @@ func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// AnalyzeMultipleTracks analyzes multiple FLAC files
|
||||
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
||||
if len(filePaths) == 0 {
|
||||
return "", fmt.Errorf("at least one file path is required")
|
||||
@@ -534,7 +557,7 @@ func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
||||
for _, filePath := range filePaths {
|
||||
result, err := backend.AnalyzeTrack(filePath)
|
||||
if err != nil {
|
||||
// Skip failed analyses
|
||||
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
@@ -548,7 +571,6 @@ func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// LyricsDownloadRequest represents the request structure for downloading lyrics
|
||||
type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -564,7 +586,6 @@ type LyricsDownloadRequest struct {
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// DownloadLyrics downloads lyrics for a single track
|
||||
func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadResponse, error) {
|
||||
if req.SpotifyID == "" {
|
||||
return backend.LyricsDownloadResponse{
|
||||
@@ -600,7 +621,6 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// CoverDownloadRequest represents the request structure for downloading cover art
|
||||
type CoverDownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -615,7 +635,6 @@ type CoverDownloadRequest struct {
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return backend.CoverDownloadResponse{
|
||||
@@ -650,7 +669,125 @@ func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResp
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks the availability of a track on different streaming platforms
|
||||
type HeaderDownloadRequest struct {
|
||||
HeaderURL string `json:"header_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
func (a *App) DownloadHeader(req HeaderDownloadRequest) (backend.HeaderDownloadResponse, error) {
|
||||
if req.HeaderURL == "" {
|
||||
return backend.HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Header URL is required",
|
||||
}, fmt.Errorf("header URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return backend.HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
client := backend.NewCoverClient()
|
||||
backendReq := backend.HeaderDownloadRequest{
|
||||
HeaderURL: req.HeaderURL,
|
||||
ArtistName: req.ArtistName,
|
||||
OutputDir: req.OutputDir,
|
||||
}
|
||||
|
||||
resp, err := client.DownloadHeader(backendReq)
|
||||
if err != nil {
|
||||
return backend.HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
type GalleryImageDownloadRequest struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
ImageIndex int `json:"image_index"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
func (a *App) DownloadGalleryImage(req GalleryImageDownloadRequest) (backend.GalleryImageDownloadResponse, error) {
|
||||
if req.ImageURL == "" {
|
||||
return backend.GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Image URL is required",
|
||||
}, fmt.Errorf("image URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return backend.GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
client := backend.NewCoverClient()
|
||||
backendReq := backend.GalleryImageDownloadRequest{
|
||||
ImageURL: req.ImageURL,
|
||||
ArtistName: req.ArtistName,
|
||||
ImageIndex: req.ImageIndex,
|
||||
OutputDir: req.OutputDir,
|
||||
}
|
||||
|
||||
resp, err := client.DownloadGalleryImage(backendReq)
|
||||
if err != nil {
|
||||
return backend.GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
type AvatarDownloadRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
func (a *App) DownloadAvatar(req AvatarDownloadRequest) (backend.AvatarDownloadResponse, error) {
|
||||
if req.AvatarURL == "" {
|
||||
return backend.AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Avatar URL is required",
|
||||
}, fmt.Errorf("avatar URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return backend.AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
client := backend.NewCoverClient()
|
||||
backendReq := backend.AvatarDownloadRequest{
|
||||
AvatarURL: req.AvatarURL,
|
||||
ArtistName: req.ArtistName,
|
||||
OutputDir: req.OutputDir,
|
||||
}
|
||||
|
||||
resp, err := client.DownloadAvatar(backendReq)
|
||||
if err != nil {
|
||||
return backend.AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return "", fmt.Errorf("spotify track ID is required")
|
||||
@@ -670,32 +807,26 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed
|
||||
func (a *App) IsFFmpegInstalled() (bool, error) {
|
||||
return backend.IsFFmpegInstalled()
|
||||
}
|
||||
|
||||
// IsFFprobeInstalled checks if ffprobe is installed
|
||||
func (a *App) IsFFprobeInstalled() (bool, error) {
|
||||
return backend.IsFFprobeInstalled()
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the path to ffmpeg
|
||||
func (a *App) GetFFmpegPath() (string, error) {
|
||||
return backend.GetFFmpegPath()
|
||||
}
|
||||
|
||||
// DownloadFFmpegRequest represents a request to download ffmpeg
|
||||
type DownloadFFmpegRequest struct{}
|
||||
|
||||
// DownloadFFmpegResponse represents the response from downloading ffmpeg
|
||||
type DownloadFFmpegResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadFFmpeg downloads and installs ffmpeg
|
||||
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||
err := backend.DownloadFFmpeg(func(progress int) {
|
||||
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress)
|
||||
@@ -713,15 +844,13 @@ func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless)
|
||||
Codec string `json:"codec"`
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg
|
||||
func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResult, error) {
|
||||
backendReq := backend.ConvertAudioRequest{
|
||||
InputFiles: req.InputFiles,
|
||||
@@ -732,7 +861,6 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul
|
||||
return backend.ConvertAudio(backendReq)
|
||||
}
|
||||
|
||||
// SelectAudioFiles opens a file dialog to select audio files for conversion
|
||||
func (a *App) SelectAudioFiles() ([]string, error) {
|
||||
files, err := backend.SelectMultipleFiles(a.ctx)
|
||||
if err != nil {
|
||||
@@ -741,12 +869,10 @@ func (a *App) SelectAudioFiles() ([]string, error) {
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetFileSizes returns file sizes for a list of file paths
|
||||
func (a *App) GetFileSizes(files []string) map[string]int64 {
|
||||
return backend.GetFileSizes(files)
|
||||
}
|
||||
|
||||
// ListDirectoryFiles lists files and folders in a directory
|
||||
func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) {
|
||||
if dirPath == "" {
|
||||
return nil, fmt.Errorf("directory path is required")
|
||||
@@ -754,7 +880,6 @@ func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) {
|
||||
return backend.ListDirectory(dirPath)
|
||||
}
|
||||
|
||||
// ListAudioFilesInDir lists only audio files in a directory recursively
|
||||
func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) {
|
||||
if dirPath == "" {
|
||||
return nil, fmt.Errorf("directory path is required")
|
||||
@@ -762,7 +887,6 @@ func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) {
|
||||
return backend.ListAudioFiles(dirPath)
|
||||
}
|
||||
|
||||
// ReadFileMetadata reads metadata from an audio file
|
||||
func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) {
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required")
|
||||
@@ -770,17 +894,14 @@ func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error)
|
||||
return backend.ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
// PreviewRenameFiles generates a preview of rename operations
|
||||
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
|
||||
return backend.PreviewRename(files, format)
|
||||
}
|
||||
|
||||
// RenameFilesByMetadata renames files based on their metadata
|
||||
func (a *App) RenameFilesByMetadata(files []string, format string) []backend.RenameResult {
|
||||
return backend.RenameFiles(files, format)
|
||||
}
|
||||
|
||||
// ReadTextFile reads a text file and returns its content
|
||||
func (a *App) ReadTextFile(filePath string) (string, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
@@ -789,7 +910,6 @@ func (a *App) ReadTextFile(filePath string) (string, error) {
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// RenameFileTo renames a file to a new name (keeping same directory)
|
||||
func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||
dir := filepath.Dir(oldPath)
|
||||
ext := filepath.Ext(oldPath)
|
||||
@@ -797,13 +917,12 @@ func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
// ReadImageAsBase64 reads an image file and returns it as base64 data URL
|
||||
func (a *App) ReadImageAsBase64(filePath string) (string, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
var mimeType string
|
||||
switch ext {
|
||||
@@ -818,44 +937,115 @@ func (a *App) ReadImageAsBase64(filePath string) (string, error) {
|
||||
default:
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(content)
|
||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
|
||||
}
|
||||
|
||||
// CheckFileExistenceRequest represents a track to check for existence
|
||||
type CheckFileExistenceRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"`
|
||||
FilenameFormat string `json:"filename_format,omitempty"`
|
||||
IncludeTrackNumber bool `json:"include_track_number,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
}
|
||||
|
||||
// CheckFilesExistence checks if multiple files already exist in the output directory
|
||||
// This is done in parallel for better performance
|
||||
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []backend.FileExistenceResult {
|
||||
// Convert to backend struct format
|
||||
backendTracks := make([]struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}, len(tracks))
|
||||
type CheckFileExistenceResult struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Exists bool `json:"exists"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
for i, t := range tracks {
|
||||
backendTracks[i] = struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
}
|
||||
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
|
||||
if len(tracks) == 0 {
|
||||
return []CheckFileExistenceResult{}
|
||||
}
|
||||
|
||||
return backend.CheckFilesExistParallel(outputDir, backendTracks)
|
||||
outputDir = backend.NormalizePath(outputDir)
|
||||
|
||||
defaultFilenameFormat := "title-artist"
|
||||
|
||||
type result struct {
|
||||
index int
|
||||
result CheckFileExistenceResult
|
||||
}
|
||||
|
||||
resultsChan := make(chan result, len(tracks))
|
||||
|
||||
for i, track := range tracks {
|
||||
go func(idx int, t CheckFileExistenceRequest) {
|
||||
res := CheckFileExistenceResult{
|
||||
SpotifyID: t.SpotifyID,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
if t.TrackName == "" || t.ArtistName == "" {
|
||||
resultsChan <- result{index: idx, result: res}
|
||||
return
|
||||
}
|
||||
|
||||
filenameFormat := t.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = defaultFilenameFormat
|
||||
}
|
||||
|
||||
trackNumber := t.Position
|
||||
if t.UseAlbumTrackNumber && t.TrackNumber > 0 {
|
||||
trackNumber = t.TrackNumber
|
||||
}
|
||||
|
||||
fileExt := ".flac"
|
||||
if t.AudioFormat == "mp3" {
|
||||
fileExt = ".mp3"
|
||||
}
|
||||
|
||||
expectedFilenameBase := backend.BuildExpectedFilename(
|
||||
t.TrackName,
|
||||
t.ArtistName,
|
||||
t.AlbumName,
|
||||
t.AlbumArtist,
|
||||
t.ReleaseDate,
|
||||
filenameFormat,
|
||||
t.IncludeTrackNumber,
|
||||
trackNumber,
|
||||
t.DiscNumber,
|
||||
t.UseAlbumTrackNumber,
|
||||
)
|
||||
|
||||
expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt
|
||||
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||
res.Exists = true
|
||||
res.FilePath = expectedPath
|
||||
}
|
||||
|
||||
resultsChan <- result{index: idx, result: res}
|
||||
}(i, track)
|
||||
}
|
||||
|
||||
results := make([]CheckFileExistenceResult, len(tracks))
|
||||
for i := 0; i < len(tracks); i++ {
|
||||
r := <-resultsChan
|
||||
results[r.index] = r.result
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// SkipDownloadItem marks a download item as skipped (file already exists)
|
||||
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
||||
backend.SkipDownloadItem(itemID, filePath)
|
||||
}
|
||||
|
||||
+25
-54
@@ -63,16 +63,14 @@ func (a *AmazonDownloader) getRandomUserAgent() string {
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
||||
// Reset counter every minute
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait until the next minute
|
||||
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
@@ -82,10 +80,9 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (6 seconds = 10 requests per minute)
|
||||
if !a.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||
minDelay := 7 * time.Second // 7 seconds to be safe
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
@@ -93,7 +90,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
}
|
||||
}
|
||||
|
||||
// Decode base64 API URL
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
@@ -109,7 +105,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
|
||||
fmt.Println("Getting Amazon URL...")
|
||||
|
||||
// Retry logic for rate limit errors
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
@@ -118,11 +113,10 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
|
||||
// Update rate limit tracking
|
||||
a.lastAPICallTime = time.Now()
|
||||
a.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 { // Too Many Requests
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
if i < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
@@ -142,7 +136,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body first to handle encoding issues and provide better error messages
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
@@ -154,7 +147,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
|
||||
var songLinkResp SongLinkResponse
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
@@ -169,7 +162,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
||||
|
||||
amazonURL := amazonLink.URL
|
||||
|
||||
// Convert album URL to track URL if needed
|
||||
if strings.Contains(amazonURL, "trackAsin=") {
|
||||
parts := strings.Split(amazonURL, "trackAsin=")
|
||||
if len(parts) > 1 {
|
||||
@@ -188,12 +180,11 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("\nTrying region: %s...\n", region)
|
||||
// Decode base64 service URL
|
||||
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
// Step 1: Submit download request
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
@@ -234,7 +225,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("Waiting for download to complete...")
|
||||
|
||||
@@ -276,7 +266,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\nDownload ready!")
|
||||
|
||||
// Build download URL
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
@@ -289,7 +278,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
|
||||
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
|
||||
|
||||
// Download file
|
||||
downloadReq, err := http.NewRequest("GET", fileURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create download request: %w", err)
|
||||
@@ -310,7 +298,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
break
|
||||
}
|
||||
|
||||
// Generate filename
|
||||
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
|
||||
for _, char := range `<>:"/\|?*` {
|
||||
fileName = strings.ReplaceAll(fileName, string(char), "")
|
||||
@@ -319,7 +306,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
// Save file
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create file: %w", err)
|
||||
@@ -328,7 +314,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
defer out.Close()
|
||||
|
||||
fmt.Println("Downloading...")
|
||||
// Use progress writer to track download
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, fileResp.Body)
|
||||
if err != nil {
|
||||
@@ -336,7 +322,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// Print final size
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
fmt.Println("Download complete!")
|
||||
return filePath, nil
|
||||
@@ -349,7 +334,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
// Still processing
|
||||
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
@@ -372,15 +357,14 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
|
||||
// Create output directory if needed
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
@@ -393,29 +377,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
|
||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||
|
||||
// Download from service
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Rename file based on Spotify metadata
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Extract year from release date
|
||||
year := ""
|
||||
if len(spotifyReleaseDate) >= 4 {
|
||||
year = spotifyReleaseDate[:4]
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
var newFilename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
newFilename = filenameFormat
|
||||
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
|
||||
@@ -424,34 +403,31 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if spotifyDiscNumber > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||
} else {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
|
||||
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
newFilename = safeTitle
|
||||
default: // "title-artist"
|
||||
default:
|
||||
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
||||
}
|
||||
@@ -460,7 +436,6 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
newFilename = newFilename + ".flac"
|
||||
newFilePath := filepath.Join(outputDir, newFilename)
|
||||
|
||||
// Rename file
|
||||
if err := os.Rename(filePath, newFilePath); err != nil {
|
||||
fmt.Printf("Warning: Failed to rename file: %v\n", err)
|
||||
} else {
|
||||
@@ -469,11 +444,10 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
// Embed Spotify metadata (replace Amazon's embedded metadata)
|
||||
fmt.Println("Embedding Spotify metadata...")
|
||||
|
||||
coverPath := ""
|
||||
// Download Spotify cover (with max resolution if enabled)
|
||||
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filePath + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
@@ -486,27 +460,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
// Determine track number to embed
|
||||
// Use Spotify track number (album track number) if available, otherwise use position
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = position // Fallback to playlist position
|
||||
}
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
// Build metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: spotifyTrackName,
|
||||
Artist: spotifyArtistName,
|
||||
Album: spotifyAlbumName,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // Use ISRC from Spotify
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
@@ -521,12 +492,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
|
||||
// Get Amazon URL from Spotify track ID
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
|
||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
||||
}
|
||||
|
||||
+5
-30
@@ -9,7 +9,6 @@ import (
|
||||
mewflac "github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// AnalysisResult contains the audio analysis data
|
||||
type AnalysisResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
@@ -25,19 +24,16 @@ type AnalysisResult struct {
|
||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyzeTrack performs audio analysis on a FLAC file
|
||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
// Get file size
|
||||
fileInfo, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Parse FLAC file
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
@@ -48,68 +44,55 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
FileSize: fileInfo.Size(),
|
||||
}
|
||||
|
||||
// Extract basic audio properties from STREAMINFO block
|
||||
if len(f.Meta) > 0 {
|
||||
streamInfo := f.Meta[0]
|
||||
if streamInfo.Type == flac.StreamInfo {
|
||||
// Read STREAMINFO data
|
||||
|
||||
data := streamInfo.Data
|
||||
if len(data) >= 18 {
|
||||
// Sample rate (bits 10-29 of bytes 10-13)
|
||||
|
||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||
|
||||
// Channels (bits 30-32 of byte 12)
|
||||
result.Channels = ((data[12] >> 1) & 0x07) + 1
|
||||
|
||||
// Bits per sample (bits 33-37 of bytes 12-13)
|
||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
||||
|
||||
// Total samples (bits 38-73 of bytes 13-17)
|
||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||
uint64(data[14])<<24 |
|
||||
uint64(data[15])<<16 |
|
||||
uint64(data[16])<<8 |
|
||||
uint64(data[17])
|
||||
|
||||
// Calculate duration
|
||||
if result.SampleRate > 0 {
|
||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
||||
}
|
||||
|
||||
// Read min/max frame size and block size for additional analysis
|
||||
// Min block size (bytes 0-1)
|
||||
// Max block size (bytes 2-3)
|
||||
// These can give us hints about encoding quality
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze spectrum and calculate real audio metrics
|
||||
spectrum, err := AnalyzeSpectrum(filepath)
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
|
||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
||||
} else {
|
||||
result.Spectrum = spectrum
|
||||
// Calculate dynamic range, peak, and RMS from decoded samples
|
||||
|
||||
calculateRealAudioMetrics(result, filepath)
|
||||
}
|
||||
|
||||
// Set bit depth
|
||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
|
||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
||||
// Decode FLAC to get actual samples
|
||||
|
||||
samples, err := decodeFLACForMetrics(filepath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate peak amplitude
|
||||
var peak float64
|
||||
var sumSquares float64
|
||||
|
||||
@@ -124,20 +107,16 @@ func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
||||
sumSquares += sample * sample
|
||||
}
|
||||
|
||||
// Convert peak to dB (reference: 1.0 = 0 dBFS)
|
||||
peakDB := 20.0 * math.Log10(peak)
|
||||
result.PeakAmplitude = peakDB
|
||||
|
||||
// Calculate RMS (Root Mean Square)
|
||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
||||
rmsDB := 20.0 * math.Log10(rms)
|
||||
result.RMSLevel = rmsDB
|
||||
|
||||
// Dynamic range is the difference between peak and RMS
|
||||
result.DynamicRange = peakDB - rmsDB
|
||||
}
|
||||
|
||||
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
|
||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
||||
stream, err := mewflac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
@@ -145,24 +124,20 @@ func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
|
||||
maxSamples := 10000000
|
||||
samples := make([]float64, 0, maxSamples)
|
||||
|
||||
// Read all audio frames
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Get samples from first channel (mono or left channel)
|
||||
var channelSamples []int32
|
||||
if len(frame.Subframes) > 0 {
|
||||
channelSamples = frame.Subframes[0].Samples
|
||||
}
|
||||
|
||||
// Normalize samples to -1.0 to 1.0 range
|
||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
||||
for _, sample := range channelSamples {
|
||||
if len(samples) >= maxSamples {
|
||||
|
||||
+2
-3
@@ -6,13 +6,12 @@ import (
|
||||
)
|
||||
|
||||
func GetDefaultMusicPath() string {
|
||||
// Get user's home directory
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Fallback to Public Music if can't get home dir
|
||||
|
||||
return "C:\\Users\\Public\\Music"
|
||||
}
|
||||
|
||||
// Return path to user's Music folder
|
||||
return filepath.Join(homeDir, "Music")
|
||||
}
|
||||
|
||||
+303
-46
@@ -12,12 +12,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Spotify image size codes
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
// CoverDownloadRequest represents a request to download cover art
|
||||
type CoverDownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -32,7 +30,6 @@ type CoverDownloadRequest struct {
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// CoverDownloadResponse represents the response from cover download
|
||||
type CoverDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
@@ -41,26 +38,36 @@ type CoverDownloadResponse struct {
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
// CoverClient handles cover art downloading
|
||||
type HeaderDownloadRequest struct {
|
||||
HeaderURL string `json:"header_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type HeaderDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
type CoverClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewCoverClient creates a new cover client
|
||||
func NewCoverClient() *CoverClient {
|
||||
return &CoverClient{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// buildCoverFilename builds the cover filename based on settings (same as track filename)
|
||||
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
@@ -68,7 +75,6 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
||||
|
||||
var filename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
@@ -77,72 +83,58 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title-artist":
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default: // "title-artist"
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
filename = fmt.Sprintf("%02d - %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".jpg"
|
||||
return filename + ".cover.jpg"
|
||||
}
|
||||
|
||||
// getMaxResolutionURL converts a Spotify cover URL to max resolution
|
||||
// Falls back to original URL if max resolution is not available
|
||||
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
|
||||
// Try to convert to max resolution
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
// Check if max resolution URL is available
|
||||
resp, err := c.httpClient.Head(maxURL)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize640) {
|
||||
return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
// Return original URL as fallback
|
||||
return coverURL
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// DownloadCoverToPath downloads cover art from URL to a specific path
|
||||
// If embedMaxQualityCover is true, it will try to get max resolution
|
||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
// Use max quality URL if setting is enabled
|
||||
downloadURL := coverURL
|
||||
if embedMaxQualityCover {
|
||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
||||
}
|
||||
|
||||
// Download cover image
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %v", err)
|
||||
@@ -153,14 +145,12 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
|
||||
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create file
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write content to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %v", err)
|
||||
@@ -169,7 +159,6 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return &CoverDownloadResponse{
|
||||
@@ -178,7 +167,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
}, fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
@@ -193,15 +181,13 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
}, err
|
||||
}
|
||||
|
||||
// Generate filename using same format as track
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist" // default
|
||||
filenameFormat = "title-artist"
|
||||
}
|
||||
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &CoverDownloadResponse{
|
||||
Success: true,
|
||||
@@ -211,10 +197,8 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try to get max resolution URL, fallback to original
|
||||
downloadURL := c.getMaxResolutionURL(req.CoverURL)
|
||||
|
||||
// Download cover image
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
@@ -231,7 +215,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create file
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
@@ -241,7 +224,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write content to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
@@ -256,3 +238,278 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
|
||||
if req.HeaderURL == "" {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Header URL is required",
|
||||
}, fmt.Errorf("header URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Header file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.HeaderURL)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download header: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &HeaderDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write header file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &HeaderDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Header downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type GalleryImageDownloadRequest struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
ImageIndex int `json:"image_index"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type GalleryImageDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
|
||||
if req.ImageURL == "" {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Image URL is required",
|
||||
}, fmt.Errorf("image URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Gallery image file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.ImageURL)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download gallery image: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &GalleryImageDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Gallery image downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AvatarDownloadRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
}
|
||||
|
||||
type AvatarDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
|
||||
if req.AvatarURL == "" {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Avatar URL is required",
|
||||
}, fmt.Errorf("avatar URL is required")
|
||||
}
|
||||
|
||||
if req.ArtistName == "" {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Artist name is required",
|
||||
}, fmt.Errorf("artist name is required")
|
||||
}
|
||||
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
|
||||
filePath := filepath.Join(artistFolder, filename)
|
||||
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Avatar file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(req.AvatarURL)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download avatar: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &AvatarDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write avatar file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &AvatarDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Avatar downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+95
-77
@@ -13,11 +13,11 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
)
|
||||
|
||||
// decodeBase64 decodes a base64 encoded string
|
||||
func decodeBase64(encoded string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
@@ -26,14 +26,12 @@ func decodeBase64(encoded string) (string, error) {
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
// ValidateExecutable checks if the path points to a valid, safe executable
|
||||
func ValidateExecutable(path string) error {
|
||||
cleanedPath := filepath.Clean(path)
|
||||
if cleanedPath == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
|
||||
// Ensure path is absolute
|
||||
if !filepath.IsAbs(cleanedPath) {
|
||||
return fmt.Errorf("path must be absolute: %s", path)
|
||||
}
|
||||
@@ -47,14 +45,12 @@ func ValidateExecutable(path string) error {
|
||||
return fmt.Errorf("path is a directory: %s", path)
|
||||
}
|
||||
|
||||
// Check executable permission on Unix
|
||||
if runtime.GOOS != "windows" {
|
||||
if info.Mode()&0111 == 0 {
|
||||
return fmt.Errorf("file is not executable: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate filename allowlist
|
||||
base := filepath.Base(cleanedPath)
|
||||
validNames := map[string]bool{
|
||||
"ffmpeg": true,
|
||||
@@ -76,7 +72,6 @@ const (
|
||||
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
|
||||
)
|
||||
|
||||
// GetFFmpegDir returns the directory where ffmpeg should be stored
|
||||
func GetFFmpegDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -85,7 +80,6 @@ func GetFFmpegDir() (string, error) {
|
||||
return filepath.Join(homeDir, ".spotiflac"), nil
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the full path to the ffmpeg executable
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
@@ -100,7 +94,6 @@ func GetFFmpegPath() (string, error) {
|
||||
return filepath.Join(ffmpegDir, ffmpegName), nil
|
||||
}
|
||||
|
||||
// GetFFprobePath returns the full path to the ffprobe executable in app directory
|
||||
func GetFFprobePath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
@@ -120,7 +113,6 @@ func GetFFprobePath() (string, error) {
|
||||
return "", fmt.Errorf("ffprobe not found in app directory")
|
||||
}
|
||||
|
||||
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
@@ -131,14 +123,12 @@ func IsFFprobeInstalled() (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
|
||||
func IsFFmpegInstalled() (bool, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
@@ -149,33 +139,35 @@ func IsFFmpegInstalled() (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
// Hide console window on Windows
|
||||
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
|
||||
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
|
||||
if runtime.GOOS == "darwin" {
|
||||
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||
|
||||
if !ffmpegInstalled && !ffprobeInstalled {
|
||||
// Download both
|
||||
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
|
||||
@@ -188,14 +180,14 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
||||
}
|
||||
} else if !ffmpegInstalled {
|
||||
// Only download ffmpeg
|
||||
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !ffprobeInstalled {
|
||||
// Only download ffprobe
|
||||
|
||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
@@ -205,7 +197,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
|
||||
var encodedURL string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
@@ -216,7 +207,6 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// Decode URL
|
||||
url, err := decodeBase64(encodedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
|
||||
@@ -231,9 +221,8 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadAndExtract downloads a file and extracts it
|
||||
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||
// Create temporary file for download
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
@@ -241,7 +230,6 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Download the file
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
@@ -254,8 +242,16 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64
|
||||
lastTime := time.Now()
|
||||
var lastBytes int64
|
||||
|
||||
if totalSize > 0 {
|
||||
totalSizeMB := float64(totalSize) / (1024 * 1024)
|
||||
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
|
||||
}
|
||||
|
||||
// Create a progress reader
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
@@ -265,12 +261,46 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
return fmt.Errorf("failed to write to temp file: %w", writeErr)
|
||||
}
|
||||
downloaded += int64(n)
|
||||
|
||||
mbDownloaded := float64(downloaded) / (1024 * 1024)
|
||||
now := time.Now()
|
||||
timeDiff := now.Sub(lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
|
||||
if timeDiff > 0.1 {
|
||||
bytesDiff := float64(downloaded - lastBytes)
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
lastTime = now
|
||||
lastBytes = downloaded
|
||||
}
|
||||
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
if speedMBps > 0 {
|
||||
SetDownloadSpeed(speedMBps)
|
||||
}
|
||||
|
||||
if totalSize > 0 && progressCallback != nil {
|
||||
// Scale progress between progressStart and progressEnd
|
||||
rawProgress := float64(downloaded) / float64(totalSize)
|
||||
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
|
||||
progressCallback(scaledProgress)
|
||||
}
|
||||
|
||||
if totalSize > 0 {
|
||||
percent := float64(downloaded) * 100 / float64(totalSize)
|
||||
if speedMBps > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
|
||||
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
|
||||
mbDownloaded, float64(totalSize)/(1024*1024), percent)
|
||||
}
|
||||
} else {
|
||||
if speedMBps > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
@@ -282,16 +312,20 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
|
||||
tmpFile.Close()
|
||||
|
||||
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
|
||||
if totalSize > 0 {
|
||||
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
|
||||
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
|
||||
} else {
|
||||
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
|
||||
}
|
||||
fmt.Printf("[FFmpeg] Extracting...\n")
|
||||
|
||||
// Extract the archive based on file type
|
||||
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
|
||||
return extractTarXz(tmpFile.Name(), destDir)
|
||||
}
|
||||
return extractZip(tmpFile.Name(), destDir)
|
||||
}
|
||||
|
||||
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
|
||||
func extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
@@ -323,7 +357,7 @@ func extractZip(zipPath, destDir string) error {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
// Skip ffplay and other files
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -351,7 +385,6 @@ func extractZip(zipPath, destDir string) error {
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
@@ -366,7 +399,6 @@ func extractZip(zipPath, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
|
||||
func extractTarXz(tarXzPath, destDir string) error {
|
||||
file, err := os.Open(tarXzPath)
|
||||
if err != nil {
|
||||
@@ -409,7 +441,7 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
// Skip ffplay and other files
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -430,7 +462,6 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
@@ -445,15 +476,13 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"` // mp3, m4a
|
||||
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" (ignored for ALAC)
|
||||
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
|
||||
OutputFormat string `json:"output_format"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
Codec string `json:"codec"`
|
||||
}
|
||||
|
||||
// ConvertAudioResult represents the result of a single file conversion
|
||||
type ConvertAudioResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
@@ -461,7 +490,6 @@ type ConvertAudioResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg while preserving metadata
|
||||
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
@@ -481,7 +509,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Convert files in parallel
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
@@ -491,16 +518,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
// Get input file info
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
|
||||
outputFormatUpper := strings.ToUpper(req.OutputFormat)
|
||||
outputDir := filepath.Join(inputDir, outputFormatUpper)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||
result.Success = false
|
||||
@@ -510,11 +534,9 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine output path
|
||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||
|
||||
// Skip if same format
|
||||
if inputExt == outputExt {
|
||||
result.Error = "Input and output formats are the same"
|
||||
result.Success = false
|
||||
@@ -526,9 +548,14 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
|
||||
result.OutputFile = outputFile
|
||||
|
||||
// Extract cover art and lyrics from input file before conversion
|
||||
var coverArtPath string
|
||||
var lyrics string
|
||||
var inputMetadata Metadata
|
||||
|
||||
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||
}
|
||||
|
||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||
lyrics, err = ExtractLyrics(inputFile)
|
||||
@@ -540,49 +567,42 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
|
||||
}
|
||||
|
||||
// Build ffmpeg command
|
||||
inputMetadata.Lyrics = lyrics
|
||||
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y", // Overwrite output
|
||||
"-y",
|
||||
}
|
||||
|
||||
// Add codec and bitrate based on output format
|
||||
switch req.OutputFormat {
|
||||
case "mp3":
|
||||
args = append(args,
|
||||
"-codec:a", "libmp3lame",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
|
||||
"-map", "0:a",
|
||||
"-id3v2_version", "3",
|
||||
)
|
||||
// Map video stream if exists (for cover art)
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
||||
case "m4a":
|
||||
// Determine codec: ALAC (lossless) or AAC (lossy)
|
||||
|
||||
codec := req.Codec
|
||||
if codec == "" {
|
||||
codec = "aac" // Default to AAC for backward compatibility
|
||||
codec = "aac"
|
||||
}
|
||||
|
||||
if codec == "alac" {
|
||||
// ALAC - Apple Lossless (no bitrate needed)
|
||||
|
||||
args = append(args,
|
||||
"-codec:a", "alac",
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-map", "0:a",
|
||||
)
|
||||
} else {
|
||||
// AAC - lossy with bitrate
|
||||
|
||||
args = append(args,
|
||||
"-codec:a", "aac",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-map", "0:a",
|
||||
)
|
||||
}
|
||||
// Map video stream for cover art in M4A
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||
}
|
||||
|
||||
args = append(args, outputFile)
|
||||
@@ -590,7 +610,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
// Hide console window on Windows
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -599,21 +619,17 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
// Clean up temp cover art file if exists
|
||||
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Embed cover art and lyrics after conversion if they were extracted
|
||||
if coverArtPath != "" {
|
||||
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
|
||||
}
|
||||
os.Remove(coverArtPath) // Clean up temp file
|
||||
if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
|
||||
}
|
||||
|
||||
if lyrics != "" {
|
||||
@@ -624,6 +640,10 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
|
||||
|
||||
@@ -637,7 +657,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetAudioInfo returns information about an audio file
|
||||
type AudioFileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
@@ -645,7 +664,6 @@ type AudioFileInfo struct {
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// GetAudioFileInfo gets information about an audio file
|
||||
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// setHideWindow is a no-op on non-Windows platforms
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
// No-op on Unix-like systems
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setHideWindow sets HideWindow attribute for Windows processes
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// SelectMultipleFiles opens a file dialog to select multiple audio files
|
||||
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Audio Files",
|
||||
@@ -39,7 +38,6 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// SelectOutputDirectory opens a directory dialog to select output folder
|
||||
func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Output Directory",
|
||||
@@ -49,4 +47,3 @@ func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
|
||||
+3
-38
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
// FileInfo represents information about a file or folder
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
@@ -23,7 +22,6 @@ type FileInfo struct {
|
||||
Children []FileInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// AudioMetadata represents metadata read from an audio file
|
||||
type AudioMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
@@ -34,7 +32,6 @@ type AudioMetadata struct {
|
||||
Year string `json:"year"`
|
||||
}
|
||||
|
||||
// RenamePreview represents a preview of file rename operation
|
||||
type RenamePreview struct {
|
||||
OldPath string `json:"old_path"`
|
||||
OldName string `json:"old_name"`
|
||||
@@ -44,7 +41,6 @@ type RenamePreview struct {
|
||||
Metadata AudioMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
// RenameResult represents the result of a rename operation
|
||||
type RenameResult struct {
|
||||
OldPath string `json:"old_path"`
|
||||
NewPath string `json:"new_path"`
|
||||
@@ -52,7 +48,6 @@ type RenameResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ListDirectory lists files and folders in a directory
|
||||
func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
@@ -73,7 +68,6 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||
Size: info.Size(),
|
||||
}
|
||||
|
||||
// If it's a directory, recursively list its contents
|
||||
if entry.IsDir() {
|
||||
children, err := ListDirectory(fileInfo.Path)
|
||||
if err == nil {
|
||||
@@ -87,13 +81,12 @@ func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
|
||||
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||
var result []FileInfo
|
||||
|
||||
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip files with errors
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
@@ -120,7 +113,6 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadAudioMetadata reads metadata from an audio file
|
||||
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
|
||||
if !fileExists(filePath) {
|
||||
return nil, fmt.Errorf("file does not exist")
|
||||
@@ -140,7 +132,6 @@ func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// readFlacMetadata reads metadata from a FLAC file
|
||||
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -192,7 +183,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readMp3Metadata reads metadata from an MP3 file
|
||||
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
@@ -207,14 +197,12 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
Year: tag.Year(),
|
||||
}
|
||||
|
||||
// Get Album Artist (TPE2)
|
||||
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
metadata.AlbumArtist = textFrame.Text
|
||||
}
|
||||
}
|
||||
|
||||
// Get Track Number
|
||||
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
trackStr := strings.Split(textFrame.Text, "/")[0]
|
||||
@@ -224,7 +212,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get Disc Number
|
||||
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
discStr := strings.Split(textFrame.Text, "/")[0]
|
||||
@@ -237,7 +224,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
|
||||
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
@@ -248,7 +234,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
|
||||
}
|
||||
|
||||
// Use ffprobe to get metadata in JSON format (both format and stream tags)
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
@@ -257,7 +242,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
filePath,
|
||||
)
|
||||
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.Output()
|
||||
@@ -265,7 +249,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var result struct {
|
||||
Format struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
@@ -281,22 +264,18 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
// Merge tags from format and streams (format tags take priority)
|
||||
allTags := make(map[string]string)
|
||||
|
||||
// First add stream tags
|
||||
for _, stream := range result.Streams {
|
||||
for key, value := range stream.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Then add format tags (overwrite stream tags)
|
||||
for key, value := range result.Format.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
for key, value := range allTags {
|
||||
switch key {
|
||||
case "title":
|
||||
@@ -308,7 +287,7 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
case "album_artist", "albumartist":
|
||||
metadata.AlbumArtist = value
|
||||
case "track":
|
||||
// Format might be "4" or "4/12"
|
||||
|
||||
trackStr := strings.Split(value, "/")[0]
|
||||
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||
metadata.TrackNumber = num
|
||||
@@ -328,7 +307,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readM4aMetadata reads metadata from an M4A file using ffprobe
|
||||
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||
metadata, err := readMetadataWithFFprobe(filePath)
|
||||
if err != nil {
|
||||
@@ -337,7 +315,6 @@ func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// GenerateFilename generates a new filename based on metadata and format template
|
||||
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
@@ -345,38 +322,32 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
||||
|
||||
result := format
|
||||
|
||||
// Extract year (first 4 characters only)
|
||||
year := metadata.Year
|
||||
if len(year) >= 4 {
|
||||
year = year[:4]
|
||||
}
|
||||
|
||||
// Replace placeholders
|
||||
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
|
||||
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
|
||||
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||
|
||||
// Track number with padding
|
||||
if metadata.TrackNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{track}", "")
|
||||
}
|
||||
|
||||
// Disc number
|
||||
if metadata.DiscNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{disc}", "")
|
||||
}
|
||||
|
||||
// Clean up multiple spaces and trim
|
||||
result = strings.TrimSpace(result)
|
||||
result = strings.Join(strings.Fields(result), " ")
|
||||
|
||||
// Remove leading/trailing separators
|
||||
result = strings.Trim(result, " -._")
|
||||
|
||||
if result == "" {
|
||||
@@ -386,9 +357,8 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
||||
return result + ext
|
||||
}
|
||||
|
||||
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
|
||||
func sanitizeFilenameForRename(name string) string {
|
||||
// Remove characters that are invalid in filenames
|
||||
|
||||
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
|
||||
result := name
|
||||
for _, char := range invalid {
|
||||
@@ -397,7 +367,6 @@ func sanitizeFilenameForRename(name string) string {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// PreviewRename generates a preview of rename operations
|
||||
func PreviewRename(files []string, format string) []RenamePreview {
|
||||
var previews []RenamePreview
|
||||
|
||||
@@ -434,7 +403,6 @@ func PreviewRename(files []string, format string) []RenamePreview {
|
||||
return previews
|
||||
}
|
||||
|
||||
// GetFileSizes returns file sizes for a list of file paths
|
||||
func GetFileSizes(files []string) map[string]int64 {
|
||||
result := make(map[string]int64)
|
||||
for _, filePath := range files {
|
||||
@@ -446,7 +414,6 @@ func GetFileSizes(files []string) map[string]int64 {
|
||||
return result
|
||||
}
|
||||
|
||||
// RenameFiles renames files based on their metadata
|
||||
func RenameFiles(files []string, format string) []RenameResult {
|
||||
var results []RenameResult
|
||||
|
||||
@@ -476,7 +443,6 @@ func RenameFiles(files []string, format string) []RenameResult {
|
||||
newPath := filepath.Join(filepath.Dir(filePath), newName)
|
||||
result.NewPath = newPath
|
||||
|
||||
// Check if new path already exists (and is different from old path)
|
||||
if newPath != filePath {
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
result.Error = "File already exists"
|
||||
@@ -486,7 +452,6 @@ func RenameFiles(files []string, format string) []RenameResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
if err := os.Rename(filePath, newPath); err != nil {
|
||||
result.Error = err.Error()
|
||||
result.Success = false
|
||||
|
||||
+13
-48
@@ -9,15 +9,13 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// BuildExpectedFilename builds the expected filename based on track metadata and settings
|
||||
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
||||
// Sanitize track name and artist name
|
||||
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
@@ -25,7 +23,6 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
||||
|
||||
var filename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
@@ -34,34 +31,31 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators like ". " or " - " or ". "
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default: // "title-artist"
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
@@ -70,109 +64,81 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(name string) string {
|
||||
// Replace forward slash with space (more natural than underscore)
|
||||
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
// Remove other invalid filesystem characters (replace with space)
|
||||
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F)
|
||||
var result strings.Builder
|
||||
for _, r := range sanitized {
|
||||
// Keep printable characters and valid Unicode characters
|
||||
// Remove control characters, but keep spaces, tabs, newlines for now
|
||||
|
||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
if r == 0x7F {
|
||||
continue
|
||||
}
|
||||
// Remove emoji and other symbols that might cause issues
|
||||
// Keep letters, numbers, and common punctuation
|
||||
|
||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
// Remove emoji ranges (most emoji are in these ranges)
|
||||
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
|
||||
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
|
||||
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
|
||||
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
|
||||
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
|
||||
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
|
||||
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
|
||||
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
|
||||
continue
|
||||
}
|
||||
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
sanitized = result.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
|
||||
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
|
||||
// Normalize consecutive spaces to single space
|
||||
re = regexp.MustCompile(`\s+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Normalize consecutive underscores to single underscore
|
||||
re = regexp.MustCompile(`_+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||
|
||||
// Remove leading/trailing underscores and spaces
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Ensure the result is valid UTF-8
|
||||
if !utf8.ValidString(sanitized) {
|
||||
// If invalid UTF-8, try to fix it
|
||||
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// NormalizePath only normalizes path separators without modifying folder names
|
||||
// Use this for user-provided paths that already exist on the filesystem
|
||||
func NormalizePath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
|
||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
}
|
||||
|
||||
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
|
||||
// Use this only for NEW folders being created (artist names, album names, etc.)
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
|
||||
// Detect separator
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
// Split path into components
|
||||
parts := strings.Split(normalizedPath, sep)
|
||||
sanitizedParts := make([]string, 0, len(parts))
|
||||
|
||||
for i, part := range parts {
|
||||
// Keep drive letter intact on Windows (e.g., "C:")
|
||||
|
||||
if i == 0 && len(part) == 2 && part[1] == ':' {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
|
||||
if i == 0 && part == "" {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize each folder name (but don't replace / or \ since we already normalized)
|
||||
sanitized := sanitizeFolderName(part)
|
||||
if sanitized != "" {
|
||||
sanitizedParts = append(sanitizedParts, sanitized)
|
||||
@@ -182,8 +148,7 @@ func SanitizeFolderPath(folderPath string) string {
|
||||
return strings.Join(sanitizedParts, sep)
|
||||
}
|
||||
|
||||
// sanitizeFolderName removes invalid characters from a single folder name
|
||||
func sanitizeFolderName(name string) string {
|
||||
// Use the same sanitization as filename
|
||||
|
||||
return sanitizeFilename(name)
|
||||
}
|
||||
|
||||
+2
-4
@@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("explorer", path)
|
||||
case "darwin": // macOS
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
@@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error {
|
||||
}
|
||||
|
||||
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
|
||||
// If defaultPath is empty, use default music path
|
||||
|
||||
if defaultPath == "" {
|
||||
defaultPath = GetDefaultMusicPath()
|
||||
}
|
||||
@@ -41,7 +41,6 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If user cancelled, selectedPath will be empty
|
||||
if selectedPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -69,7 +68,6 @@ func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If user cancelled, selectedFile will be empty
|
||||
if selectedFile == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
+77
-57
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LRCLibResponse represents the LRCLIB API response
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -27,21 +26,18 @@ type LRCLibResponse struct {
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
// LyricsLine represents a single line of lyrics
|
||||
type LyricsLine struct {
|
||||
StartTimeMs string `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
EndTimeMs string `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
// LyricsResponse represents the API response
|
||||
type LyricsResponse struct {
|
||||
Error bool `json:"error"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []LyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
// LyricsDownloadRequest represents a request to download lyrics
|
||||
type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -57,7 +53,6 @@ type LyricsDownloadRequest struct {
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// LyricsDownloadResponse represents the response from lyrics download
|
||||
type LyricsDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
@@ -66,27 +61,28 @@ type LyricsDownloadResponse struct {
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
// LyricsClient handles lyrics fetching
|
||||
type LyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewLyricsClient creates a new lyrics client
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
|
||||
// Try LRCLIB API
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
|
||||
apiURL := fmt.Sprintf("%s%s&track_name=%s",
|
||||
string(apiBase),
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
if duration > 0 {
|
||||
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
|
||||
@@ -107,11 +103,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*L
|
||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
// Convert LRCLIB response to our LyricsResponse format
|
||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||
}
|
||||
|
||||
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
|
||||
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
|
||||
resp := &LyricsResponse{
|
||||
Error: false,
|
||||
@@ -119,7 +113,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
Lines: []LyricsLine{},
|
||||
}
|
||||
|
||||
// Prefer synced lyrics, fall back to plain
|
||||
lyricsText := lrcLib.SyncedLyrics
|
||||
if lyricsText == "" {
|
||||
lyricsText = lrcLib.PlainLyrics
|
||||
@@ -131,7 +124,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
return resp
|
||||
}
|
||||
|
||||
// Parse synced lyrics format [mm:ss.xx] text
|
||||
lines := strings.Split(lyricsText, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -139,14 +131,12 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if line has timestamp [mm:ss.xx]
|
||||
if strings.HasPrefix(line, "[") && len(line) > 10 {
|
||||
closeBracket := strings.Index(line, "]")
|
||||
if closeBracket > 0 {
|
||||
timestamp := line[1:closeBracket]
|
||||
words := strings.TrimSpace(line[closeBracket+1:])
|
||||
|
||||
// Convert [mm:ss.xx] to milliseconds
|
||||
ms := lrcTimestampToMs(timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: fmt.Sprintf("%d", ms),
|
||||
@@ -156,9 +146,8 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
}
|
||||
}
|
||||
|
||||
// Plain lyrics line (no timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: "0",
|
||||
StartTimeMs: "",
|
||||
Words: line,
|
||||
})
|
||||
}
|
||||
@@ -166,10 +155,9 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly
|
||||
return resp
|
||||
}
|
||||
|
||||
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
|
||||
func lrcTimestampToMs(timestamp string) int64 {
|
||||
var minutes, seconds, centiseconds int64
|
||||
// Try parsing mm:ss.xx format
|
||||
|
||||
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||
if n >= 2 {
|
||||
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||
@@ -177,7 +165,6 @@ func lrcTimestampToMs(timestamp string) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
|
||||
@@ -207,7 +194,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
|
||||
return nil, fmt.Errorf("no results found")
|
||||
}
|
||||
|
||||
// Find best match - prefer one with synced lyrics
|
||||
var best *LRCLibResponse
|
||||
for i := range results {
|
||||
if results[i].SyncedLyrics != "" {
|
||||
@@ -226,41 +212,37 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
|
||||
return c.convertLRCLibToLyricsResponse(best), nil
|
||||
}
|
||||
|
||||
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
|
||||
func simplifyTrackName(name string) string {
|
||||
// Remove content in parentheses
|
||||
|
||||
if idx := strings.Index(name, "("); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
// Remove content after " - " (like "From the Motion Picture")
|
||||
|
||||
if idx := strings.Index(name, " - "); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
|
||||
// 1. Try LRCLIB exact match
|
||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
|
||||
|
||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
||||
|
||||
// 2. Try LRCLIB search
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: %v\n", err)
|
||||
|
||||
// 3. Try with simplified track name (remove parentheses, subtitles)
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB (simplified)", nil
|
||||
}
|
||||
@@ -274,31 +256,31 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||
}
|
||||
|
||||
// ConvertToLRC converts lyrics response to LRC format
|
||||
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Add metadata
|
||||
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
sb.WriteString("[by:SpotiFlac]\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Add lyrics lines
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert milliseconds to LRC timestamp format [mm:ss.xx]
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
|
||||
if line.StartTimeMs == "" {
|
||||
sb.WriteString(fmt.Sprintf("%s\n", line.Words))
|
||||
} else {
|
||||
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
|
||||
func msToLRCTimestamp(msStr string) string {
|
||||
var ms int64
|
||||
fmt.Sscanf(msStr, "%d", &ms)
|
||||
@@ -311,14 +293,12 @@ func msToLRCTimestamp(msStr string) string {
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
|
||||
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
@@ -326,7 +306,6 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
|
||||
|
||||
var filename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
@@ -335,34 +314,31 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default: // "title-artist"
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
@@ -371,7 +347,47 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
|
||||
return filename + ".lrc"
|
||||
}
|
||||
|
||||
// DownloadLyrics downloads lyrics for a single track
|
||||
func findAudioFileForLyrics(dir, trackName, artistName string) string {
|
||||
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
|
||||
audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"}
|
||||
|
||||
patterns := []string{
|
||||
fmt.Sprintf("%s - %s", safeTitle, safeArtist),
|
||||
fmt.Sprintf("%s - %s", safeArtist, safeTitle),
|
||||
safeTitle,
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := entry.Name()
|
||||
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
for _, audioExt := range audioExts {
|
||||
if ext == strings.ToLower(audioExt) {
|
||||
return filepath.Join(dir, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
|
||||
if req.SpotifyID == "" {
|
||||
return &LyricsDownloadResponse{
|
||||
@@ -380,7 +396,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}, fmt.Errorf("spotify ID is required")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
@@ -395,15 +410,13 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}, err
|
||||
}
|
||||
|
||||
// Generate filename using same format as track
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist" // default
|
||||
filenameFormat = "title-artist"
|
||||
}
|
||||
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: true,
|
||||
@@ -413,8 +426,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch lyrics from LRCLIB
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
audioDuration := 0
|
||||
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
|
||||
if audioFile != "" {
|
||||
duration, err := GetAudioDuration(audioFile)
|
||||
if err == nil && duration > 0 {
|
||||
audioDuration = int(duration)
|
||||
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
|
||||
}
|
||||
}
|
||||
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
|
||||
if err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
@@ -422,10 +444,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}, err
|
||||
}
|
||||
|
||||
// Convert to LRC format
|
||||
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
|
||||
|
||||
// Write LRC file
|
||||
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
|
||||
+480
-203
@@ -1,13 +1,13 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
pathfilepath "path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
"github.com/go-flac/flacpicture"
|
||||
@@ -20,12 +20,15 @@ type Metadata struct {
|
||||
Artist string
|
||||
Album string
|
||||
AlbumArtist string
|
||||
Date string // Recorded date (full date YYYY-MM-DD)
|
||||
ReleaseDate string // Release date (full date) - kept for compatibility
|
||||
Date string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
TotalTracks int // Total tracks in album
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
TotalDiscs int
|
||||
URL string
|
||||
Copyright string
|
||||
Publisher string
|
||||
Lyrics string
|
||||
Description string
|
||||
}
|
||||
@@ -70,15 +73,21 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||
if metadata.DiscNumber > 0 {
|
||||
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
if metadata.ISRC != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
|
||||
if metadata.TotalDiscs > 0 {
|
||||
_ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs))
|
||||
}
|
||||
if metadata.Copyright != "" {
|
||||
_ = cmt.Add("COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
if metadata.Publisher != "" {
|
||||
_ = cmt.Add("PUBLISHER", metadata.Publisher)
|
||||
}
|
||||
if metadata.Description != "" {
|
||||
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
||||
}
|
||||
// Lyrics is added last to keep it at the bottom
|
||||
|
||||
if metadata.Lyrics != "" {
|
||||
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced
|
||||
_ = cmt.Add("LYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
@@ -135,20 +144,17 @@ func fileExists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// extractYear extracts the year from a release date string
|
||||
// Handles formats: "YYYY-MM-DD", "YYYY-MM", "YYYY"
|
||||
func extractYear(releaseDate string) string {
|
||||
if releaseDate == "" {
|
||||
return ""
|
||||
}
|
||||
// Try to extract year (first 4 digits)
|
||||
|
||||
if len(releaseDate) >= 4 {
|
||||
return releaseDate[:4]
|
||||
}
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata
|
||||
func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
@@ -171,10 +177,8 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create new comment block, preserving existing comments
|
||||
cmt := flacvorbis.New()
|
||||
|
||||
// Copy existing comments except LYRICS
|
||||
if existingCmt != nil {
|
||||
for _, comment := range existingCmt.Comments {
|
||||
parts := strings.SplitN(comment, "=", 2)
|
||||
@@ -187,7 +191,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Add lyrics
|
||||
_ = cmt.Add("LYRICS", lyrics)
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
@@ -204,82 +207,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadISRCFromFile reads ISRC metadata from a FLAC file
|
||||
func ReadISRCFromFile(filepath string) (string, error) {
|
||||
if !fileExists(filepath) {
|
||||
return "", fmt.Errorf("file does not exist")
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
// Find VorbisComment block
|
||||
for _, block := range f.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get ISRC field
|
||||
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
|
||||
if err == nil && len(isrcValues) > 0 {
|
||||
return isrcValues[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil // No ISRC found
|
||||
}
|
||||
|
||||
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
|
||||
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
|
||||
if targetISRC == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Read all .flac files in directory
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check only .flac files
|
||||
filename := entry.Name()
|
||||
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
|
||||
continue
|
||||
}
|
||||
|
||||
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
|
||||
|
||||
// Read ISRC from file (this will fail for corrupted files)
|
||||
isrc, err := ReadISRCFromFile(filepath)
|
||||
if err != nil {
|
||||
// File is corrupted or unreadable, delete it
|
||||
fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
|
||||
if removeErr := os.Remove(filepath); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare ISRC (case-insensitive)
|
||||
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
|
||||
return filepath, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ExtractCoverArt extracts cover art from an audio file and saves it to a temporary file
|
||||
func ExtractCoverArt(filePath string) (string, error) {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
@@ -293,7 +220,6 @@ func ExtractCoverArt(filePath string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// extractCoverFromMp3 extracts cover art from MP3 file
|
||||
func extractCoverFromMp3(filePath string) (string, error) {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
@@ -311,7 +237,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
|
||||
return "", fmt.Errorf("invalid picture frame")
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
@@ -326,7 +251,6 @@ func extractCoverFromMp3(filePath string) (string, error) {
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file
|
||||
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
@@ -343,7 +267,6 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
@@ -361,12 +284,9 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
||||
return "", fmt.Errorf("no cover art found")
|
||||
}
|
||||
|
||||
// For M4A, try to extract using ffmpeg or return empty
|
||||
// M4A cover art should be preserved by ffmpeg during conversion
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ExtractLyrics extracts lyrics from an audio file
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
@@ -376,14 +296,13 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
case ".flac":
|
||||
return extractLyricsFromFlac(filePath)
|
||||
case ".m4a":
|
||||
// M4A lyrics extraction would need different approach
|
||||
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// extractLyricsFromMp3 extracts lyrics from MP3 file
|
||||
func extractLyricsFromMp3(filePath string) (string, error) {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
@@ -412,7 +331,6 @@ func extractLyricsFromMp3(filePath string) (string, error) {
|
||||
return uslt.Lyrics, nil
|
||||
}
|
||||
|
||||
// extractLyricsFromFlac extracts lyrics from FLAC file
|
||||
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -426,7 +344,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search through comments for lyrics
|
||||
for _, comment := range cmt.Comments {
|
||||
parts := strings.SplitN(comment, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
@@ -445,7 +362,6 @@ func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// EmbedCoverArtOnly embeds cover art into an audio file
|
||||
func EmbedCoverArtOnly(filePath string, coverPath string) error {
|
||||
if coverPath == "" || !fileExists(coverPath) {
|
||||
return nil
|
||||
@@ -457,16 +373,13 @@ func EmbedCoverArtOnly(filePath string, coverPath string) error {
|
||||
case ".mp3":
|
||||
return embedCoverToMp3(filePath, coverPath)
|
||||
case ".m4a":
|
||||
// M4A cover art should be handled by ffmpeg during conversion
|
||||
// If not, we can try to embed using atomicparsley or similar tool
|
||||
// For now, return nil as ffmpeg should handle it
|
||||
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// embedCoverToMp3 embeds cover art into MP3 file
|
||||
func embedCoverToMp3(filePath string, coverPath string) error {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
@@ -474,16 +387,13 @@ func embedCoverToMp3(filePath string, coverPath string) error {
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
// Remove existing cover art
|
||||
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||
|
||||
// Read cover art
|
||||
artwork, err := os.ReadFile(coverPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cover art: %w", err)
|
||||
}
|
||||
|
||||
// Add new cover art
|
||||
pic := id3v2.PictureFrame{
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
MimeType: "image/jpeg",
|
||||
@@ -500,27 +410,30 @@ func embedCoverToMp3(filePath string, coverPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame
|
||||
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
||||
if err != nil {
|
||||
fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
||||
validatedLyrics = lyrics
|
||||
}
|
||||
lyrics = validatedLyrics
|
||||
|
||||
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
// Remove existing USLT frames
|
||||
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
||||
|
||||
// Add new USLT frame with lyrics
|
||||
// Use UTF-8 encoding for better compatibility with AIMP and other players
|
||||
usltFrame := id3v2.UnsynchronisedLyricsFrame{
|
||||
Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
Language: "eng",
|
||||
ContentDescriptor: "", // Empty descriptor for better compatibility
|
||||
ContentDescriptor: "",
|
||||
Lyrics: lyrics,
|
||||
}
|
||||
tag.AddUnsynchronisedLyricsFrame(usltFrame)
|
||||
@@ -532,10 +445,15 @@ func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
|
||||
func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||
// Use ffmpeg to embed lyrics into M4A file
|
||||
// M4A uses iTunes metadata format with atom '©lyr' for lyrics
|
||||
|
||||
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
||||
if err != nil {
|
||||
fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
||||
validatedLyrics = lyrics
|
||||
}
|
||||
lyrics = validatedLyrics
|
||||
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||
@@ -545,18 +463,14 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
// Create temporary output file with proper extension so ffmpeg can detect format
|
||||
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
|
||||
defer func() {
|
||||
// Only remove if file still exists (rename might have failed)
|
||||
|
||||
if _, err := os.Stat(tmpOutputFile); err == nil {
|
||||
os.Remove(tmpOutputFile)
|
||||
}
|
||||
}()
|
||||
|
||||
// Use ffmpeg to copy file and add lyrics metadata
|
||||
// For M4A, we need to use the correct metadata tag format and specify output format
|
||||
// Use -f ipod for M4A format (iPod format is compatible with M4A)
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-i", filepath,
|
||||
"-map", "0",
|
||||
@@ -564,12 +478,11 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||
"-metadata", "lyrics-eng="+lyrics,
|
||||
"-metadata", "lyrics="+lyrics,
|
||||
"-codec", "copy",
|
||||
"-f", "ipod", // Explicitly specify M4A/iPod format
|
||||
"-y", // Overwrite
|
||||
"-f", "ipod",
|
||||
"-y",
|
||||
tmpOutputFile,
|
||||
)
|
||||
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
@@ -578,7 +491,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
|
||||
}
|
||||
|
||||
// Replace original file with new file
|
||||
if err := os.Rename(tmpOutputFile, filepath); err != nil {
|
||||
return fmt.Errorf("failed to replace original file: %w", err)
|
||||
}
|
||||
@@ -587,7 +499,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
|
||||
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
@@ -606,85 +517,451 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// FileExistenceResult represents the result of checking if a file exists
|
||||
type FileExistenceResult struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Exists bool `json:"exists"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
func GetAudioDuration(filepath string) (float64, error) {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
||||
|
||||
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||
func CheckFilesExistParallel(outputDir string, tracks []struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}) []FileExistenceResult {
|
||||
results := make([]FileExistenceResult, len(tracks))
|
||||
|
||||
// Build ISRC index from output directory (scan once)
|
||||
isrcIndex := buildISRCIndex(outputDir)
|
||||
|
||||
// Check each track against the index (parallel)
|
||||
var wg sync.WaitGroup
|
||||
for i, track := range tracks {
|
||||
wg.Add(1)
|
||||
go func(idx int, t struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}) {
|
||||
defer wg.Done()
|
||||
|
||||
result := FileExistenceResult{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
if t.ISRC != "" {
|
||||
if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
results[idx] = result
|
||||
}(i, track)
|
||||
if ext == ".flac" {
|
||||
duration, err := getFlacDuration(filepath)
|
||||
if err == nil && duration > 0 {
|
||||
return duration, nil
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
return getDurationWithFFprobe(filepath)
|
||||
}
|
||||
|
||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||
func buildISRCIndex(outputDir string) map[string]string {
|
||||
index := make(map[string]string)
|
||||
func getFlacDuration(filepath string) (float64, error) {
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Walk directory recursively - only check .flac files for SpotiFLAC
|
||||
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
if len(f.Meta) > 0 {
|
||||
streamInfo := f.Meta[0]
|
||||
if streamInfo.Type == flac.StreamInfo {
|
||||
data := streamInfo.Data
|
||||
if len(data) >= 18 {
|
||||
|
||||
sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||
|
||||
totalSamples := uint64(data[13]&0x0F)<<32 |
|
||||
uint64(data[14])<<24 |
|
||||
uint64(data[15])<<16 |
|
||||
uint64(data[16])<<8 |
|
||||
uint64(data[17])
|
||||
|
||||
if sampleRate > 0 {
|
||||
return float64(totalSamples) / float64(sampleRate), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext := strings.ToLower(pathfilepath.Ext(path))
|
||||
if ext != ".flac" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read ISRC from file
|
||||
isrc, err := ReadISRCFromFile(path)
|
||||
if err != nil || isrc == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store in index (uppercase for case-insensitive matching)
|
||||
index[strings.ToUpper(isrc)] = path
|
||||
return nil
|
||||
})
|
||||
|
||||
return index
|
||||
return 0, fmt.Errorf("could not extract duration from FLAC file")
|
||||
}
|
||||
|
||||
func getDurationWithFFprobe(filepath string) (float64, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
filepath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if result.Format.Duration == "" {
|
||||
return 0, fmt.Errorf("duration not found in ffprobe output")
|
||||
}
|
||||
|
||||
duration, err := strconv.ParseFloat(result.Format.Duration, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func validateLyricsDuration(lyrics string, filepath string) (string, error) {
|
||||
|
||||
duration, err := GetAudioDuration(filepath)
|
||||
if err != nil {
|
||||
|
||||
fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
if duration <= 0 {
|
||||
|
||||
fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
durationMs := int64(duration * 1000)
|
||||
|
||||
lines := strings.Split(lyrics, "\n")
|
||||
var validLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
if trimmedLine == "" {
|
||||
validLines = append(validLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmedLine, "[") {
|
||||
|
||||
if strings.Index(trimmedLine, ":") > 0 {
|
||||
|
||||
validLines = append(validLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
closeBracket := strings.Index(trimmedLine, "]")
|
||||
if closeBracket > 0 {
|
||||
timestampStr := trimmedLine[1:closeBracket]
|
||||
|
||||
ms := parseLRCTimestamp(timestampStr)
|
||||
if ms >= 0 && ms <= durationMs {
|
||||
|
||||
validLines = append(validLines, line)
|
||||
} else {
|
||||
|
||||
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
|
||||
}
|
||||
} else {
|
||||
|
||||
validLines = append(validLines, line)
|
||||
}
|
||||
} else {
|
||||
|
||||
validLines = append(validLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(validLines, "\n"), nil
|
||||
}
|
||||
|
||||
func parseLRCTimestamp(timestamp string) int64 {
|
||||
var minutes, seconds, centiseconds int64
|
||||
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||
if n >= 2 {
|
||||
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||
var metadata Metadata
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return metadata, err
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||
return metadata, fmt.Errorf("invalid ffprobe executable: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return metadata, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Format struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
return metadata, err
|
||||
}
|
||||
|
||||
allTags := make(map[string]string)
|
||||
|
||||
for _, stream := range result.Streams {
|
||||
for key, value := range stream.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range result.Format.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
|
||||
for key, value := range allTags {
|
||||
switch key {
|
||||
case "title":
|
||||
metadata.Title = value
|
||||
case "artist":
|
||||
metadata.Artist = value
|
||||
case "album":
|
||||
metadata.Album = value
|
||||
case "album_artist", "albumartist":
|
||||
metadata.AlbumArtist = value
|
||||
case "date", "year":
|
||||
if metadata.Date == "" || len(value) > len(metadata.Date) {
|
||||
metadata.Date = value
|
||||
}
|
||||
case "track":
|
||||
|
||||
parts := strings.Split(value, "/")
|
||||
if len(parts) > 0 {
|
||||
if num, err := strconv.Atoi(parts[0]); err == nil {
|
||||
metadata.TrackNumber = num
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
if num, err := strconv.Atoi(parts[1]); err == nil {
|
||||
metadata.TotalTracks = num
|
||||
}
|
||||
}
|
||||
case "disc":
|
||||
|
||||
parts := strings.Split(value, "/")
|
||||
if len(parts) > 0 {
|
||||
if num, err := strconv.Atoi(parts[0]); err == nil {
|
||||
metadata.DiscNumber = num
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
if num, err := strconv.Atoi(parts[1]); err == nil {
|
||||
metadata.TotalDiscs = num
|
||||
}
|
||||
}
|
||||
case "copyright", "tcop":
|
||||
metadata.Copyright = value
|
||||
case "publisher", "tpub", "label":
|
||||
metadata.Publisher = value
|
||||
case "url":
|
||||
metadata.URL = value
|
||||
case "description", "comment":
|
||||
if metadata.Description == "" {
|
||||
metadata.Description = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
|
||||
return EmbedMetadata(filePath, metadata, coverPath)
|
||||
case ".mp3":
|
||||
return embedMetadataToMP3(filePath, metadata, coverPath)
|
||||
case ".m4a":
|
||||
return embedMetadataToM4A(filePath, metadata, coverPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) error {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||
}
|
||||
defer tag.Close()
|
||||
|
||||
tag.DeleteFrames("TXXX")
|
||||
|
||||
if metadata.Title != "" {
|
||||
tag.SetTitle(metadata.Title)
|
||||
}
|
||||
if metadata.Artist != "" {
|
||||
tag.SetArtist(metadata.Artist)
|
||||
}
|
||||
if metadata.Album != "" {
|
||||
tag.SetAlbum(metadata.Album)
|
||||
}
|
||||
if metadata.Date != "" {
|
||||
year := metadata.Date
|
||||
if len(year) >= 4 {
|
||||
year = year[:4]
|
||||
}
|
||||
tag.SetYear(year)
|
||||
}
|
||||
|
||||
if metadata.AlbumArtist != "" {
|
||||
tag.DeleteFrames("TPE2")
|
||||
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
|
||||
}
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
|
||||
trackStr := strconv.Itoa(metadata.TrackNumber)
|
||||
if metadata.TotalTracks > 0 {
|
||||
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
|
||||
}
|
||||
tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr)
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
tag.DeleteFrames(tag.CommonID("Part of a set"))
|
||||
discStr := strconv.Itoa(metadata.DiscNumber)
|
||||
if metadata.TotalDiscs > 0 {
|
||||
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
|
||||
}
|
||||
tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
tag.DeleteFrames("TCOP")
|
||||
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Publisher != "" {
|
||||
tag.DeleteFrames("TPUB")
|
||||
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
|
||||
}
|
||||
|
||||
if coverPath != "" && fileExists(coverPath) {
|
||||
|
||||
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||
|
||||
artwork, err := os.ReadFile(coverPath)
|
||||
if err == nil {
|
||||
pic := id3v2.PictureFrame{
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
MimeType: "image/jpeg",
|
||||
PictureType: id3v2.PTFrontCover,
|
||||
Description: "Cover",
|
||||
Picture: artwork,
|
||||
}
|
||||
tag.AddAttachedPicture(pic)
|
||||
} else {
|
||||
fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tag.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-i", filePath,
|
||||
"-y",
|
||||
}
|
||||
|
||||
if coverPath != "" && fileExists(coverPath) {
|
||||
args = append(args, "-i", coverPath)
|
||||
args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||
} else {
|
||||
args = append(args, "-map", "0", "-codec", "copy")
|
||||
}
|
||||
|
||||
if metadata.Title != "" {
|
||||
args = append(args, "-metadata", "title="+metadata.Title)
|
||||
}
|
||||
if metadata.Artist != "" {
|
||||
args = append(args, "-metadata", "artist="+metadata.Artist)
|
||||
}
|
||||
if metadata.Album != "" {
|
||||
args = append(args, "-metadata", "album="+metadata.Album)
|
||||
}
|
||||
if metadata.AlbumArtist != "" {
|
||||
args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist)
|
||||
}
|
||||
if metadata.Date != "" {
|
||||
args = append(args, "-metadata", "date="+metadata.Date)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
trackStr := strconv.Itoa(metadata.TrackNumber)
|
||||
if metadata.TotalTracks > 0 {
|
||||
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
|
||||
}
|
||||
args = append(args, "-metadata", "track="+trackStr)
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
discStr := strconv.Itoa(metadata.DiscNumber)
|
||||
if metadata.TotalDiscs > 0 {
|
||||
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
|
||||
}
|
||||
args = append(args, "-metadata", "disk="+discStr)
|
||||
}
|
||||
if metadata.Copyright != "" {
|
||||
args = append(args, "-metadata", "copyright="+metadata.Copyright)
|
||||
}
|
||||
if metadata.Publisher != "" {
|
||||
args = append(args, "-metadata", "publisher="+metadata.Publisher)
|
||||
}
|
||||
|
||||
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
|
||||
defer func() {
|
||||
if _, err := os.Stat(tmpOutputFile); err == nil {
|
||||
os.Remove(tmpOutputFile)
|
||||
}
|
||||
}()
|
||||
|
||||
args = append(args, "-f", "ipod", tmpOutputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpOutputFile, filePath); err != nil {
|
||||
return fmt.Errorf("failed to replace original file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+14
-54
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadStatus represents the status of a download item
|
||||
type DownloadStatus string
|
||||
|
||||
const (
|
||||
@@ -18,7 +17,6 @@ const (
|
||||
StatusSkipped DownloadStatus = "skipped"
|
||||
)
|
||||
|
||||
// DownloadItem represents a single item in the download queue
|
||||
type DownloadItem struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -26,16 +24,15 @@ type DownloadItem struct {
|
||||
AlbumName string `json:"album_name"`
|
||||
ISRC string `json:"isrc"`
|
||||
Status DownloadStatus `json:"status"`
|
||||
Progress float64 `json:"progress"` // MB downloaded
|
||||
TotalSize float64 `json:"total_size"` // MB total (if known)
|
||||
Speed float64 `json:"speed"` // MB/s
|
||||
StartTime int64 `json:"start_time"` // Unix timestamp
|
||||
EndTime int64 `json:"end_time"` // Unix timestamp
|
||||
ErrorMessage string `json:"error_message"` // If failed
|
||||
FilePath string `json:"file_path"` // Final file path
|
||||
Progress float64 `json:"progress"`
|
||||
TotalSize float64 `json:"total_size"`
|
||||
Speed float64 `json:"speed"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
// Global progress tracker
|
||||
var (
|
||||
currentProgress float64
|
||||
currentProgressLock sync.RWMutex
|
||||
@@ -44,7 +41,6 @@ var (
|
||||
currentSpeed float64
|
||||
speedLock sync.RWMutex
|
||||
|
||||
// Download queue tracking
|
||||
downloadQueue []DownloadItem
|
||||
downloadQueueLock sync.RWMutex
|
||||
currentItemID string
|
||||
@@ -55,27 +51,24 @@ var (
|
||||
sessionStartLock sync.RWMutex
|
||||
)
|
||||
|
||||
// ProgressInfo represents download progress information
|
||||
type ProgressInfo struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
MBDownloaded float64 `json:"mb_downloaded"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
}
|
||||
|
||||
// DownloadQueueInfo represents the complete download queue state
|
||||
type DownloadQueueInfo struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Queue []DownloadItem `json:"queue"`
|
||||
CurrentSpeed float64 `json:"current_speed"` // MB/s
|
||||
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
|
||||
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
|
||||
CurrentSpeed float64 `json:"current_speed"`
|
||||
TotalDownloaded float64 `json:"total_downloaded"`
|
||||
SessionStartTime int64 `json:"session_start_time"`
|
||||
QueuedCount int `json:"queued_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
}
|
||||
|
||||
// GetDownloadProgress returns current download progress
|
||||
func GetDownloadProgress() ProgressInfo {
|
||||
downloadingLock.RLock()
|
||||
downloading := isDownloading
|
||||
@@ -96,34 +89,30 @@ func GetDownloadProgress() ProgressInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// SetDownloadSpeed updates the current download speed
|
||||
func SetDownloadSpeed(mbps float64) {
|
||||
speedLock.Lock()
|
||||
currentSpeed = mbps
|
||||
speedLock.Unlock()
|
||||
}
|
||||
|
||||
// SetDownloadProgress updates the current download progress
|
||||
func SetDownloadProgress(mbDownloaded float64) {
|
||||
currentProgressLock.Lock()
|
||||
currentProgress = mbDownloaded
|
||||
currentProgressLock.Unlock()
|
||||
}
|
||||
|
||||
// SetDownloading sets the downloading state
|
||||
func SetDownloading(downloading bool) {
|
||||
downloadingLock.Lock()
|
||||
isDownloading = downloading
|
||||
downloadingLock.Unlock()
|
||||
|
||||
if !downloading {
|
||||
// Reset progress when download completes
|
||||
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressWriter wraps an io.Writer and reports download progress
|
||||
type ProgressWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
@@ -131,7 +120,7 @@ type ProgressWriter struct {
|
||||
startTime int64
|
||||
lastTime int64
|
||||
lastBytes int64
|
||||
itemID string // Track which download item this belongs to
|
||||
itemID string
|
||||
}
|
||||
|
||||
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||
@@ -147,7 +136,6 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||
}
|
||||
}
|
||||
|
||||
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
|
||||
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
|
||||
pw := NewProgressWriter(writer)
|
||||
pw.itemID = itemID
|
||||
@@ -162,13 +150,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.total += int64(n)
|
||||
|
||||
// Report progress every 256KB for smoother updates
|
||||
if pw.total-pw.lastPrinted >= 256*1024 {
|
||||
mbDownloaded := float64(pw.total) / (1024 * 1024)
|
||||
|
||||
// Calculate speed (MB/s)
|
||||
now := getCurrentTimeMillis()
|
||||
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
|
||||
timeDiff := float64(now-pw.lastTime) / 1000.0
|
||||
bytesDiff := float64(pw.total - pw.lastBytes)
|
||||
|
||||
var speedMBps float64
|
||||
@@ -180,10 +166,8 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
|
||||
}
|
||||
|
||||
// Update global progress
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
|
||||
// Update individual item progress if we have an item ID
|
||||
if pw.itemID != "" {
|
||||
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
|
||||
}
|
||||
@@ -200,9 +184,6 @@ func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
// Queue management functions
|
||||
|
||||
// AddToQueue adds a new item to the download queue
|
||||
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
@@ -223,7 +204,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||
|
||||
downloadQueue = append(downloadQueue, item)
|
||||
|
||||
// Initialize session start time if this is the first item
|
||||
sessionStartLock.Lock()
|
||||
if sessionStartTime == 0 {
|
||||
sessionStartTime = time.Now().Unix()
|
||||
@@ -231,7 +211,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||
sessionStartLock.Unlock()
|
||||
}
|
||||
|
||||
// StartDownloadItem marks an item as currently downloading
|
||||
func StartDownloadItem(id string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
@@ -250,7 +229,6 @@ func StartDownloadItem(id string) {
|
||||
currentItemLock.Unlock()
|
||||
}
|
||||
|
||||
// UpdateItemProgress updates the progress of the current download item
|
||||
func UpdateItemProgress(id string, progress, speed float64) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
@@ -264,14 +242,12 @@ func UpdateItemProgress(id string, progress, speed float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentItemID returns the ID of the currently downloading item
|
||||
func GetCurrentItemID() string {
|
||||
currentItemLock.RLock()
|
||||
defer currentItemLock.RUnlock()
|
||||
return currentItemID
|
||||
}
|
||||
|
||||
// CompleteDownloadItem marks an item as completed
|
||||
func CompleteDownloadItem(id, filePath string, finalSize float64) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
@@ -284,7 +260,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
|
||||
downloadQueue[i].Progress = finalSize
|
||||
downloadQueue[i].TotalSize = finalSize
|
||||
|
||||
// Add to total downloaded
|
||||
totalDownloadedLock.Lock()
|
||||
totalDownloaded += finalSize
|
||||
totalDownloadedLock.Unlock()
|
||||
@@ -293,7 +268,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// FailDownloadItem marks an item as failed
|
||||
func FailDownloadItem(id, errorMsg string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
@@ -308,7 +282,6 @@ func FailDownloadItem(id, errorMsg string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SkipDownloadItem marks an item as skipped (already exists)
|
||||
func SkipDownloadItem(id, filePath string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
@@ -323,9 +296,8 @@ func SkipDownloadItem(id, filePath string) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetDownloadQueue returns the complete download queue state
|
||||
func GetDownloadQueue() DownloadQueueInfo {
|
||||
// Auto-reset session if all downloads are complete
|
||||
|
||||
ResetSessionIfComplete()
|
||||
|
||||
downloadQueueLock.RLock()
|
||||
@@ -347,7 +319,6 @@ func GetDownloadQueue() DownloadQueueInfo {
|
||||
sessionStart := sessionStartTime
|
||||
sessionStartLock.RUnlock()
|
||||
|
||||
// Count statuses
|
||||
var queued, completed, failed, skipped int
|
||||
for _, item := range downloadQueue {
|
||||
switch item.Status {
|
||||
@@ -362,7 +333,6 @@ func GetDownloadQueue() DownloadQueueInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a copy of the queue
|
||||
queueCopy := make([]DownloadItem, len(downloadQueue))
|
||||
copy(queueCopy, downloadQueue)
|
||||
|
||||
@@ -379,12 +349,10 @@ func GetDownloadQueue() DownloadQueueInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
|
||||
func ClearDownloadQueue() {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
// Keep only queued and downloading items
|
||||
newQueue := make([]DownloadItem, 0)
|
||||
for _, item := range downloadQueue {
|
||||
if item.Status == StatusQueued || item.Status == StatusDownloading {
|
||||
@@ -394,7 +362,6 @@ func ClearDownloadQueue() {
|
||||
downloadQueue = newQueue
|
||||
}
|
||||
|
||||
// ClearAllDownloads clears the entire queue and resets session stats
|
||||
func ClearAllDownloads() {
|
||||
downloadQueueLock.Lock()
|
||||
downloadQueue = []DownloadItem{}
|
||||
@@ -412,13 +379,10 @@ func ClearAllDownloads() {
|
||||
currentItemID = ""
|
||||
currentItemLock.Unlock()
|
||||
|
||||
// Reset current progress and speed
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
}
|
||||
|
||||
// CancelAllQueuedItems marks all queued items as skipped (cancelled)
|
||||
// This is called when user stops a download or when batch download completes
|
||||
func CancelAllQueuedItems() {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
@@ -432,8 +396,6 @@ func CancelAllQueuedItems() {
|
||||
}
|
||||
}
|
||||
|
||||
// ResetSessionIfComplete resets session stats if no active or queued downloads
|
||||
// Note: Does NOT clear the queue - items remain visible for history
|
||||
func ResetSessionIfComplete() {
|
||||
downloadQueueLock.RLock()
|
||||
hasActiveOrQueued := false
|
||||
@@ -445,8 +407,6 @@ func ResetSessionIfComplete() {
|
||||
}
|
||||
downloadQueueLock.RUnlock()
|
||||
|
||||
// If no active or queued items, reset session stats
|
||||
// But keep the queue items for history visibility
|
||||
if !hasActiveOrQueued {
|
||||
sessionStartLock.Lock()
|
||||
sessionStartTime = 0
|
||||
|
||||
+26
-47
@@ -78,7 +78,7 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
// Decode base64 API URL
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
||||
|
||||
@@ -93,7 +93,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
// Read body first to handle encoding issues
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
@@ -104,7 +104,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
@@ -120,20 +120,17 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
// Map quality to Qobuz quality code
|
||||
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
|
||||
qualityCode := quality // Use the provided quality parameter
|
||||
|
||||
qualityCode := quality
|
||||
if qualityCode == "" {
|
||||
qualityCode = "6" // Default to FLAC 16-bit if not specified
|
||||
qualityCode = "6"
|
||||
}
|
||||
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
|
||||
|
||||
// Decode base64 API URLs
|
||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||
|
||||
// Try primary API first
|
||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
|
||||
|
||||
@@ -154,7 +151,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Fallback to secondary API
|
||||
fmt.Println("Primary API failed, trying fallback...")
|
||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||
@@ -184,7 +180,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
@@ -202,10 +198,9 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||
fmt.Println("Starting file download...")
|
||||
// Use a separate client with a longer timeout. The default client's 60s limit
|
||||
// causes downloads to fail on slow connections or for large Hi-Res files.
|
||||
|
||||
downloadClient := &http.Client{
|
||||
Timeout: 5 * time.Minute, // 5 minutes for large files
|
||||
Timeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Get(url)
|
||||
@@ -226,14 +221,13 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||
defer out.Close()
|
||||
|
||||
fmt.Println("Downloading...")
|
||||
// Use progress writer to track download
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
// Print final size
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
@@ -266,19 +260,16 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Determine track number to use
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
@@ -287,34 +278,31 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
default:
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
@@ -323,22 +311,20 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.SearchByISRC(isrc)
|
||||
track, err := q.SearchByISRC(deezerISRC)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// All metadata from Spotify - no fallback to Qobuz
|
||||
artists := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
@@ -362,7 +348,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
return "", fmt.Errorf("received empty download URL")
|
||||
}
|
||||
|
||||
// Show partial URL for security
|
||||
urlPreview := downloadURL
|
||||
if len(downloadURL) > 60 {
|
||||
urlPreview = downloadURL[:60] + "..."
|
||||
@@ -374,13 +359,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
safeAlbum := sanitizeFilename(albumTitle)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Build filename based on format settings (use Spotify track number)
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
|
||||
@@ -397,7 +375,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
fmt.Printf("Downloaded: %s\n", filepath)
|
||||
|
||||
coverPath := ""
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
|
||||
if spotifyCoverURL != "" {
|
||||
coverPath = filepath + ".cover.jpg"
|
||||
coverClient := NewCoverClient()
|
||||
@@ -412,23 +390,24 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artists,
|
||||
Album: albumTitle,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
Date: spotifyReleaseDate,
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: isrc, // ISRC from Spotify (passed as parameter)
|
||||
TotalTracks: spotifyTotalTracks,
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
|
||||
+21
-35
@@ -5,7 +5,6 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Hiragana to Romaji mapping
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
@@ -17,20 +16,19 @@ var hiraganaToRomaji = map[rune]string{
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||
// Dakuten (voiced)
|
||||
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
// Small characters
|
||||
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'っ': "", // Double consonant marker
|
||||
'っ': "",
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
// Katakana to Romaji mapping
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
@@ -42,23 +40,22 @@ var katakanaToRomaji = map[rune]string{
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||
// Dakuten (voiced)
|
||||
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
// Small characters
|
||||
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ッ': "", // Double consonant marker
|
||||
'ッ': "",
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
// Extended katakana
|
||||
'ー': "", // Long vowel mark
|
||||
|
||||
'ー': "",
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
// Combination mappings for きゃ, しゃ, etc.
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
@@ -85,13 +82,12 @@ var combinationKatakana = map[string]string{
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
// Extended combinations
|
||||
|
||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
@@ -110,12 +106,10 @@ func isKatakana(r rune) bool {
|
||||
}
|
||||
|
||||
func isKanji(r rune) bool {
|
||||
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||
return (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||
(r >= 0x3400 && r <= 0x4DBF)
|
||||
}
|
||||
|
||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||
func JapaneseToRomaji(text string) string {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
@@ -126,7 +120,7 @@ func JapaneseToRomaji(text string) string {
|
||||
i := 0
|
||||
|
||||
for i < len(runes) {
|
||||
// Check for っ/ッ (double consonant)
|
||||
|
||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||
nextRomaji := ""
|
||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||
@@ -135,13 +129,12 @@ func JapaneseToRomaji(text string) string {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||
result.WriteByte(nextRomaji[0])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for two-character combinations
|
||||
if i < len(runes)-1 {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := combinationHiragana[combo]; ok {
|
||||
@@ -156,17 +149,16 @@ func JapaneseToRomaji(text string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Single character conversion
|
||||
r := runes[i]
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if isKanji(r) {
|
||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
// Keep other characters (punctuation, spaces, etc.)
|
||||
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
@@ -175,21 +167,17 @@ func JapaneseToRomaji(text string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// BuildSearchQuery creates a search query from track name and artist
|
||||
// Converts Japanese to romaji if present
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
// Convert Japanese to romaji
|
||||
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
artistRomaji := JapaneseToRomaji(artistName)
|
||||
|
||||
// Clean up the query - remove special characters that might interfere with search
|
||||
trackClean := cleanSearchQuery(trackRomaji)
|
||||
artistClean := cleanSearchQuery(artistRomaji)
|
||||
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
// cleanSearchQuery removes special characters that might interfere with search
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
@@ -202,21 +190,19 @@ func cleanSearchQuery(s string) string {
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||
// This is useful for creating search queries that work better with Tidal's search
|
||||
func cleanToASCII(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
} else if r == ',' || r == '.' {
|
||||
// Convert punctuation to space
|
||||
|
||||
result.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
// Clean up multiple spaces
|
||||
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
+152
-30
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,7 +23,6 @@ type SongLinkURLs struct {
|
||||
AmazonURL string `json:"amazon_url"`
|
||||
}
|
||||
|
||||
// TrackAvailability represents the availability of a track on different platforms
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
@@ -43,14 +43,13 @@ func NewSongLinkClient() *SongLinkClient {
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
|
||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait until the next minute
|
||||
if s.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
@@ -61,7 +60,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds to be safe)
|
||||
if !s.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
@@ -72,7 +70,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
}
|
||||
}
|
||||
|
||||
// Decode base64 API URL
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
@@ -86,7 +83,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
|
||||
fmt.Println("Getting streaming URLs from song.link...")
|
||||
|
||||
// Retry logic for rate limit errors
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
@@ -95,7 +91,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
return nil, fmt.Errorf("failed to get URLs: %w", err)
|
||||
}
|
||||
|
||||
// Update rate limit tracking
|
||||
s.lastAPICallTime = time.Now()
|
||||
s.apiCallCount++
|
||||
|
||||
@@ -124,7 +119,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
// Read body first to handle encoding issues
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
@@ -135,7 +130,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
@@ -145,23 +140,20 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
|
||||
urls := &SongLinkURLs{}
|
||||
|
||||
// Extract Tidal URL
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
urls.TidalURL = tidalLink.URL
|
||||
fmt.Printf("✓ Tidal URL found\n")
|
||||
}
|
||||
|
||||
// Extract Amazon URL
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
amazonURL := amazonLink.URL
|
||||
// Convert album URL to track URL if needed
|
||||
|
||||
if len(amazonURL) > 0 {
|
||||
urls.AmazonURL = amazonURL
|
||||
fmt.Printf("✓ Amazon URL found\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one URL was found
|
||||
if urls.TidalURL == "" && urls.AmazonURL == "" {
|
||||
return nil, fmt.Errorf("no streaming URLs found")
|
||||
}
|
||||
@@ -169,16 +161,14 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks the availability of a track on different platforms
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait until the next minute
|
||||
if s.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
@@ -189,7 +179,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds to be safe)
|
||||
if !s.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
@@ -200,7 +189,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
}
|
||||
|
||||
// Decode base64 API URL
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
@@ -214,7 +202,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
|
||||
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
|
||||
|
||||
// Retry logic for rate limit errors
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
@@ -223,7 +210,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
|
||||
// Update rate limit tracking
|
||||
s.lastAPICallTime = time.Now()
|
||||
s.apiCallCount++
|
||||
|
||||
@@ -252,7 +238,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
// Read body first to handle encoding issues
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
@@ -263,7 +249,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
@@ -275,33 +261,33 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
// Check Tidal
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
}
|
||||
|
||||
// Check Amazon
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
// Check Qobuz using ISRC (song.link doesn't support Qobuz)
|
||||
if isrc != "" {
|
||||
qobuzAvailable := checkQobuzAvailability(isrc)
|
||||
availability.Qobuz = qobuzAvailable
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
deezerURL := deezerLink.URL
|
||||
|
||||
deezerISRC, err := GetDeezerISRC(deezerURL)
|
||||
if err == nil && deezerISRC != "" {
|
||||
qobuzAvailable := checkQobuzAvailability(deezerISRC)
|
||||
availability.Qobuz = qobuzAvailable
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
|
||||
func checkQobuzAvailability(isrc string) bool {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
appID := "798273057"
|
||||
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
||||
|
||||
@@ -326,3 +312,139 @@ func checkQobuzAvailability(isrc string) bool {
|
||||
|
||||
return searchResp.Tracks.Total > 0
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
if s.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
if !s.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Getting Deezer URL from song.link...")
|
||||
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
|
||||
}
|
||||
|
||||
s.lastAPICallTime = time.Now()
|
||||
s.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
if i < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
|
||||
if !ok || deezerLink.URL == "" {
|
||||
return "", fmt.Errorf("deezer link not found")
|
||||
}
|
||||
|
||||
deezerURL := deezerLink.URL
|
||||
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
||||
return deezerURL, nil
|
||||
}
|
||||
|
||||
func GetDeezerISRC(deezerURL string) (string, error) {
|
||||
|
||||
var trackID string
|
||||
if strings.Contains(deezerURL, "/track/") {
|
||||
parts := strings.Split(deezerURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
trackID = strings.Split(parts[1], "?")[0]
|
||||
trackID = strings.TrimSpace(trackID)
|
||||
}
|
||||
}
|
||||
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to call Deezer API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
ISRC string `json:"isrc"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
|
||||
}
|
||||
|
||||
if deezerTrack.ISRC == "" {
|
||||
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
|
||||
}
|
||||
|
||||
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
|
||||
return deezerTrack.ISRC, nil
|
||||
}
|
||||
|
||||
+3
-17
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// SpectrumData contains frequency spectrum information
|
||||
type SpectrumData struct {
|
||||
TimeSlices []TimeSlice `json:"time_slices"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
@@ -17,15 +16,13 @@ type SpectrumData struct {
|
||||
MaxFreq float64 `json:"max_freq"`
|
||||
}
|
||||
|
||||
// TimeSlice represents spectrum data at a point in time
|
||||
type TimeSlice struct {
|
||||
Time float64 `json:"time"`
|
||||
Magnitudes []float64 `json:"magnitudes"`
|
||||
}
|
||||
|
||||
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
|
||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||
// Open FLAC file
|
||||
|
||||
stream, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
||||
@@ -36,7 +33,6 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||
sampleRate := int(info.SampleRate)
|
||||
channels := int(info.NChannels)
|
||||
|
||||
// Read audio samples
|
||||
samples, err := readSamples(stream, channels)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
||||
@@ -46,28 +42,23 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||
return nil, fmt.Errorf("no audio samples found")
|
||||
}
|
||||
|
||||
// Calculate spectrum
|
||||
return calculateSpectrum(samples, sampleRate), nil
|
||||
}
|
||||
|
||||
// readSamples reads and decodes audio samples from FLAC stream
|
||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||
var allSamples []float64
|
||||
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
|
||||
maxSamples := 10 * 1024 * 1024
|
||||
|
||||
// Decode frames
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
// End of stream
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// Convert samples to float64 and mix channels to mono
|
||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
||||
var sample float64
|
||||
|
||||
// Mix all channels to mono by averaging
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
sample += float64(frame.Subframes[ch].Samples[i])
|
||||
}
|
||||
@@ -75,7 +66,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||
|
||||
allSamples = append(allSamples, sample)
|
||||
|
||||
// Limit sample count
|
||||
if len(allSamples) >= maxSamples {
|
||||
return allSamples, nil
|
||||
}
|
||||
@@ -85,7 +75,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||
return allSamples, nil
|
||||
}
|
||||
|
||||
// calculateSpectrum performs FFT analysis on audio samples
|
||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||
fftSize := 8192
|
||||
numTimeSlices := 300
|
||||
@@ -140,7 +129,6 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||
}
|
||||
}
|
||||
|
||||
// applyHannWindow applies Hann window to reduce spectral leakage
|
||||
func applyHannWindow(samples []float64) []float64 {
|
||||
n := len(samples)
|
||||
windowed := make([]float64, n)
|
||||
@@ -153,7 +141,6 @@ func applyHannWindow(samples []float64) []float64 {
|
||||
return windowed
|
||||
}
|
||||
|
||||
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
|
||||
func fft(samples []float64) []complex128 {
|
||||
n := len(samples)
|
||||
|
||||
@@ -165,7 +152,6 @@ func fft(samples []float64) []complex128 {
|
||||
return fftRecursive(x)
|
||||
}
|
||||
|
||||
// fftRecursive performs recursive FFT
|
||||
func fftRecursive(x []complex128) []complex128 {
|
||||
n := len(x)
|
||||
|
||||
|
||||
+502
-906
File diff suppressed because it is too large
Load Diff
+69
-460
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.23.26",
|
||||
"motion": "^12.24.12",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
@@ -44,11 +44,11 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"globals": "^17.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"vite": "^7.3.0"
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0f9764c2a4597a75120d3e76c32af7a9
|
||||
be90455e8d3a26cf5c12d4fa0779bc1a
|
||||
Generated
+251
-224
@@ -43,7 +43,7 @@ importers:
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
version: 4.1.18(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -54,8 +54,8 @@ importers:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
motion:
|
||||
specifier: ^12.23.26
|
||||
version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
specifier: ^12.24.12
|
||||
version: 12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -89,7 +89,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.7)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.2
|
||||
version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
version: 5.1.2(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
@@ -100,8 +100,8 @@ importers:
|
||||
specifier: ^0.4.26
|
||||
version: 0.4.26(eslint@9.39.2(jiti@2.6.1))
|
||||
globals:
|
||||
specifier: ^16.5.0
|
||||
version: 16.5.0
|
||||
specifier: ^17.0.0
|
||||
version: 17.0.0
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -112,11 +112,11 @@ importers:
|
||||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.50.1
|
||||
version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: ^8.52.0
|
||||
version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -203,8 +203,8 @@ packages:
|
||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@emnapi/runtime@1.7.1':
|
||||
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
|
||||
'@emnapi/runtime@1.8.1':
|
||||
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||
@@ -362,8 +362,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0':
|
||||
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
|
||||
'@eslint-community/eslint-utils@4.9.1':
|
||||
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
@@ -1026,113 +1026,128 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-beta.53':
|
||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
|
||||
'@rollup/rollup-android-arm-eabi@4.55.1':
|
||||
resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
|
||||
'@rollup/rollup-android-arm64@4.55.1':
|
||||
resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
|
||||
'@rollup/rollup-darwin-arm64@4.55.1':
|
||||
resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
|
||||
'@rollup/rollup-darwin-x64@4.55.1':
|
||||
resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
|
||||
'@rollup/rollup-freebsd-arm64@4.55.1':
|
||||
resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
|
||||
'@rollup/rollup-freebsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.55.1':
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.55.1':
|
||||
resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.55.1':
|
||||
resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.55.1':
|
||||
resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
|
||||
'@rollup/rollup-win32-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
||||
resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1255,63 +1270,63 @@ packages:
|
||||
'@types/react@19.2.7':
|
||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.50.1':
|
||||
resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==}
|
||||
'@typescript-eslint/eslint-plugin@8.52.0':
|
||||
resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.50.1
|
||||
'@typescript-eslint/parser': ^8.52.0
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.50.1':
|
||||
resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==}
|
||||
'@typescript-eslint/parser@8.52.0':
|
||||
resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.50.1':
|
||||
resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==}
|
||||
'@typescript-eslint/project-service@8.52.0':
|
||||
resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.50.1':
|
||||
resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==}
|
||||
'@typescript-eslint/scope-manager@8.52.0':
|
||||
resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.50.1':
|
||||
resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==}
|
||||
'@typescript-eslint/tsconfig-utils@8.52.0':
|
||||
resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.50.1':
|
||||
resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==}
|
||||
'@typescript-eslint/type-utils@8.52.0':
|
||||
resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@8.50.1':
|
||||
resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==}
|
||||
'@typescript-eslint/types@8.52.0':
|
||||
resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.50.1':
|
||||
resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==}
|
||||
'@typescript-eslint/typescript-estree@8.52.0':
|
||||
resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.50.1':
|
||||
resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==}
|
||||
'@typescript-eslint/utils@8.52.0':
|
||||
resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.50.1':
|
||||
resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==}
|
||||
'@typescript-eslint/visitor-keys@8.52.0':
|
||||
resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@vitejs/plugin-react@5.1.2':
|
||||
@@ -1347,8 +1362,8 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
baseline-browser-mapping@2.9.11:
|
||||
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
|
||||
baseline-browser-mapping@2.9.13:
|
||||
resolution: {integrity: sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==}
|
||||
hasBin: true
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
@@ -1366,8 +1381,8 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001761:
|
||||
resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==}
|
||||
caniuse-lite@1.0.30001763:
|
||||
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
@@ -1476,8 +1491,8 @@ packages:
|
||||
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
esquery@1.6.0:
|
||||
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
|
||||
esquery@1.7.0:
|
||||
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
esrecurse@4.3.0:
|
||||
@@ -1525,8 +1540,8 @@ packages:
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
|
||||
framer-motion@12.23.26:
|
||||
resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==}
|
||||
framer-motion@12.24.12:
|
||||
resolution: {integrity: sha512-W+tBOI1SDGNMH4D4mADY95qYd16Drke2Tj9zlGlwTGSCi6yy8wbMmPY1mvirfcTK8HBeuuCd2PflHdN/zbL4ew==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
@@ -1560,8 +1575,8 @@ packages:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
globals@16.5.0:
|
||||
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
|
||||
globals@17.0.0:
|
||||
resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
@@ -1736,14 +1751,14 @@ packages:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
motion-dom@12.23.23:
|
||||
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
|
||||
motion-dom@12.24.11:
|
||||
resolution: {integrity: sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==}
|
||||
|
||||
motion-utils@12.23.6:
|
||||
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
|
||||
motion-utils@12.24.10:
|
||||
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
|
||||
|
||||
motion@12.23.26:
|
||||
resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==}
|
||||
motion@12.24.12:
|
||||
resolution: {integrity: sha512-usaP62NpHmM8++QrEnNoCco6qrtK1AtzkeHfgW+4qICE0k7ykK+dPJGaRjEzo7sF1GcrYskrGBB/r5RtqnminQ==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
@@ -1866,8 +1881,8 @@ packages:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
rollup@4.54.0:
|
||||
resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
|
||||
rollup@4.55.1:
|
||||
resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1927,8 +1942,8 @@ packages:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
ts-api-utils@2.1.0:
|
||||
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
|
||||
ts-api-utils@2.4.0:
|
||||
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
|
||||
engines: {node: '>=18.12'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
@@ -1943,8 +1958,8 @@ packages:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
typescript-eslint@8.50.1:
|
||||
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
|
||||
typescript-eslint@8.52.0:
|
||||
resolution: {integrity: sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
@@ -1987,8 +2002,8 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
vite@7.3.0:
|
||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2049,8 +2064,8 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
zod@4.2.1:
|
||||
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==}
|
||||
zod@4.3.5:
|
||||
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -2166,7 +2181,7 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@emnapi/runtime@1.7.1':
|
||||
'@emnapi/runtime@1.8.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
@@ -2249,7 +2264,7 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))':
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
@@ -2407,7 +2422,7 @@ snapshots:
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.7.1
|
||||
'@emnapi/runtime': 1.8.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
@@ -2865,70 +2880,79 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
'@rollup/rollup-android-arm-eabi@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
'@rollup/rollup-android-arm64@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
'@rollup/rollup-darwin-arm64@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
'@rollup/rollup-darwin-x64@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
'@rollup/rollup-freebsd-arm64@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
'@rollup/rollup-freebsd-x64@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
'@rollup/rollup-openharmony-arm64@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/node@4.1.18':
|
||||
@@ -2992,12 +3016,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
|
||||
|
||||
'@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
'@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.18
|
||||
'@tailwindcss/oxide': 4.1.18
|
||||
tailwindcss: 4.1.18
|
||||
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
@@ -3036,98 +3060,98 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.50.1
|
||||
'@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.50.1
|
||||
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.52.0
|
||||
'@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.52.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.50.1
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.50.1
|
||||
'@typescript-eslint/scope-manager': 8.52.0
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.52.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.50.1(typescript@5.9.3)':
|
||||
'@typescript-eslint/project-service@8.52.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
'@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.50.1':
|
||||
'@typescript-eslint/scope-manager@8.52.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
'@typescript-eslint/visitor-keys': 8.50.1
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/visitor-keys': 8.52.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)':
|
||||
'@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.50.1': {}
|
||||
'@typescript-eslint/types@8.52.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)':
|
||||
'@typescript-eslint/typescript-estree@8.52.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.50.1(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
'@typescript-eslint/visitor-keys': 8.50.1
|
||||
'@typescript-eslint/project-service': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/visitor-keys': 8.52.0
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.1.0(typescript@5.9.3)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
'@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.50.1
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.52.0
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.50.1':
|
||||
'@typescript-eslint/visitor-keys@8.52.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
'@typescript-eslint/types': 8.52.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
'@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
|
||||
@@ -3135,7 +3159,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3164,7 +3188,7 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
baseline-browser-mapping@2.9.11: {}
|
||||
baseline-browser-mapping@2.9.13: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
@@ -3177,15 +3201,15 @@ snapshots:
|
||||
|
||||
browserslist@4.28.1:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.9.11
|
||||
caniuse-lite: 1.0.30001761
|
||||
baseline-browser-mapping: 2.9.13
|
||||
caniuse-lite: 1.0.30001763
|
||||
electron-to-chromium: 1.5.267
|
||||
node-releases: 2.0.27
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001761: {}
|
||||
caniuse-lite@1.0.30001763: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
@@ -3272,8 +3296,8 @@ snapshots:
|
||||
'@babel/parser': 7.28.5
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.2.1
|
||||
zod-validation-error: 4.0.2(zod@4.2.1)
|
||||
zod: 4.3.5
|
||||
zod-validation-error: 4.0.2(zod@4.3.5)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3292,7 +3316,7 @@ snapshots:
|
||||
|
||||
eslint@9.39.2(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1))
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.1
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
@@ -3312,7 +3336,7 @@ snapshots:
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.6.0
|
||||
esquery: 1.7.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
@@ -3337,7 +3361,7 @@ snapshots:
|
||||
acorn-jsx: 5.3.2(acorn@8.15.0)
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
esquery@1.6.0:
|
||||
esquery@1.7.0:
|
||||
dependencies:
|
||||
estraverse: 5.3.0
|
||||
|
||||
@@ -3375,10 +3399,10 @@ snapshots:
|
||||
|
||||
flatted@3.3.3: {}
|
||||
|
||||
framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
framer-motion@12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
motion-dom: 12.23.23
|
||||
motion-utils: 12.23.6
|
||||
motion-dom: 12.24.11
|
||||
motion-utils: 12.24.10
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
@@ -3397,7 +3421,7 @@ snapshots:
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
globals@16.5.0: {}
|
||||
globals@17.0.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
@@ -3530,15 +3554,15 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
motion-dom@12.23.23:
|
||||
motion-dom@12.24.11:
|
||||
dependencies:
|
||||
motion-utils: 12.23.6
|
||||
motion-utils: 12.24.10
|
||||
|
||||
motion-utils@12.23.6: {}
|
||||
motion-utils@12.24.10: {}
|
||||
|
||||
motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
motion@12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
framer-motion: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
framer-motion: 12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
@@ -3634,32 +3658,35 @@ snapshots:
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
rollup@4.54.0:
|
||||
rollup@4.55.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.54.0
|
||||
'@rollup/rollup-android-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-x64': 4.54.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.54.0
|
||||
'@rollup/rollup-freebsd-x64': 4.54.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.54.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.54.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.54.0
|
||||
'@rollup/rollup-android-arm-eabi': 4.55.1
|
||||
'@rollup/rollup-android-arm64': 4.55.1
|
||||
'@rollup/rollup-darwin-arm64': 4.55.1
|
||||
'@rollup/rollup-darwin-x64': 4.55.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.55.1
|
||||
'@rollup/rollup-freebsd-x64': 4.55.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.55.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.55.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.55.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.55.1
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.55.1
|
||||
'@rollup/rollup-linux-loong64-musl': 4.55.1
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.55.1
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.55.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.55.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.55.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.55.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.55.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.55.1
|
||||
'@rollup/rollup-openbsd-x64': 4.55.1
|
||||
'@rollup/rollup-openharmony-arm64': 4.55.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.55.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.55.1
|
||||
'@rollup/rollup-win32-x64-gnu': 4.55.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.55.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
@@ -3729,7 +3756,7 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
ts-api-utils@2.1.0(typescript@5.9.3):
|
||||
ts-api-utils@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -3741,12 +3768,12 @@ snapshots:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
typescript-eslint@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -3781,13 +3808,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.54.0
|
||||
rollup: 4.55.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.3
|
||||
@@ -3805,8 +3832,8 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.2.1):
|
||||
zod-validation-error@4.0.2(zod@4.3.5):
|
||||
dependencies:
|
||||
zod: 4.2.1
|
||||
zod: 4.3.5
|
||||
|
||||
zod@4.2.1: {}
|
||||
zod@4.3.5: {}
|
||||
|
||||
+365
-613
File diff suppressed because it is too large
Load Diff
@@ -7,144 +7,85 @@ import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
|
||||
interface AlbumInfoProps {
|
||||
albumInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
albumInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
} | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onArtistClick?: (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => 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,
|
||||
}: AlbumInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
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, }: AlbumInfoProps) {
|
||||
return (<div className="space-y-6">
|
||||
<Card>
|
||||
<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 && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Album</p>
|
||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: albumInfo.artist_id!,
|
||||
name: albumInfo.artists,
|
||||
external_urls: albumInfo.artist_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
id: albumInfo.artist_id!,
|
||||
name: albumInfo.artists,
|
||||
external_urls: albumInfo.artist_url!,
|
||||
})}>
|
||||
{albumInfo.artists}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{albumInfo.artists}</span>
|
||||
)}
|
||||
</span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
|
||||
<span>•</span>
|
||||
<span>{albumInfo.release_date}</span>
|
||||
<span>•</span>
|
||||
@@ -155,119 +96,46 @@ export function AlbumInfo({
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Lyrics</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{onDownloadAllCovers && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</Button>)}
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<SearchAndSort
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={onSearchChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
<TrackList
|
||||
tracks={trackList}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
failedTracks={failedTracks}
|
||||
skippedTracks={skippedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={true}
|
||||
folderName={albumInfo.name}
|
||||
downloadedLyrics={downloadedLyrics}
|
||||
failedLyrics={failedLyrics}
|
||||
skippedLyrics={skippedLyrics}
|
||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
||||
availabilityMap={availabilityMap}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onDownloadCover={onDownloadCover}
|
||||
downloadedCovers={downloadedCovers}
|
||||
failedCovers={failedCovers}
|
||||
skippedCovers={skippedCovers}
|
||||
downloadingCoverTrack={downloadingCoverTrack}
|
||||
onCheckAvailability={onCheckAvailability}
|
||||
onPageChange={onPageChange}
|
||||
onTrackClick={onTrackClick}
|
||||
/>
|
||||
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={true} folderName={albumInfo.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,315 +1,451 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
|
||||
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { useState } from "react";
|
||||
interface ArtistInfoProps {
|
||||
artistInfo: {
|
||||
name: string;
|
||||
images: string;
|
||||
followers: number;
|
||||
genres: string[];
|
||||
};
|
||||
albumList: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
album_type: string;
|
||||
external_urls: string;
|
||||
}>;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
artistInfo: {
|
||||
name: string;
|
||||
images: string;
|
||||
header?: string;
|
||||
gallery?: string[];
|
||||
followers: number;
|
||||
genres: string[];
|
||||
biography?: string;
|
||||
verified?: boolean;
|
||||
listeners?: number;
|
||||
rank?: number;
|
||||
};
|
||||
albumList: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
album_type: string;
|
||||
external_urls: string;
|
||||
}>;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
} | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onAlbumClick: (album: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onArtistClick: (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
}
|
||||
|
||||
export function ArtistInfo({
|
||||
artistInfo,
|
||||
albumList,
|
||||
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,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
onPageChange,
|
||||
onTrackClick,
|
||||
}: ArtistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{artistInfo.images && (
|
||||
<img
|
||||
src={artistInfo.images}
|
||||
alt={artistInfo.name}
|
||||
className="w-48 h-48 rounded-full shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium">Artist</p>
|
||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||
<span>•</span>
|
||||
<span>{albumList.length} albums</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} tracks</span>
|
||||
{artistInfo.genres.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
export function ArtistInfo({ artistInfo, albumList, 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, onAlbumClick, onArtistClick, onPageChange, onTrackClick, }: ArtistInfoProps) {
|
||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
||||
const handleDownloadHeader = async () => {
|
||||
if (!artistInfo.header)
|
||||
return;
|
||||
setDownloadingHeader(true);
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const response = await downloadHeader({
|
||||
header_url: artistInfo.header,
|
||||
artist_name: artistInfo.name,
|
||||
output_dir: settings.downloadPath,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info("Header already exists");
|
||||
}
|
||||
else {
|
||||
toast.success("Header downloaded successfully");
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download header");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(`Error downloading header: ${error}`);
|
||||
}
|
||||
finally {
|
||||
setDownloadingHeader(false);
|
||||
}
|
||||
};
|
||||
const handleDownloadAvatar = async () => {
|
||||
if (!artistInfo.images)
|
||||
return;
|
||||
setDownloadingAvatar(true);
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const response = await downloadAvatar({
|
||||
avatar_url: artistInfo.images,
|
||||
artist_name: artistInfo.name,
|
||||
output_dir: settings.downloadPath,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info("Avatar already exists");
|
||||
}
|
||||
else {
|
||||
toast.success("Avatar downloaded successfully");
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download avatar");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(`Error downloading avatar: ${error}`);
|
||||
}
|
||||
finally {
|
||||
setDownloadingAvatar(false);
|
||||
}
|
||||
};
|
||||
const handleDownloadGalleryImage = async (imageUrl: string, index: number) => {
|
||||
setDownloadingGalleryIndex(index);
|
||||
try {
|
||||
const settings = getSettings();
|
||||
const response = await downloadGalleryImage({
|
||||
image_url: imageUrl,
|
||||
artist_name: artistInfo.name,
|
||||
image_index: index,
|
||||
output_dir: settings.downloadPath,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info(`Gallery image ${index + 1} already exists`);
|
||||
}
|
||||
else {
|
||||
toast.success(`Gallery image ${index + 1} downloaded successfully`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || `Failed to download gallery image ${index + 1}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(`Error downloading gallery image ${index + 1}: ${error}`);
|
||||
}
|
||||
finally {
|
||||
setDownloadingGalleryIndex(null);
|
||||
}
|
||||
};
|
||||
const handleDownloadAllGallery = async () => {
|
||||
if (!artistInfo.gallery || artistInfo.gallery.length === 0)
|
||||
return;
|
||||
setDownloadingAllGallery(true);
|
||||
try {
|
||||
const settings = getSettings();
|
||||
let successCount = 0;
|
||||
let existsCount = 0;
|
||||
let failCount = 0;
|
||||
for (let index = 0; index < artistInfo.gallery.length; index++) {
|
||||
const imageUrl = artistInfo.gallery[index];
|
||||
try {
|
||||
const response = await downloadGalleryImage({
|
||||
image_url: imageUrl,
|
||||
artist_name: artistInfo.name,
|
||||
image_index: index,
|
||||
output_dir: settings.downloadPath,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
existsCount++;
|
||||
}
|
||||
else {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
if (failCount === 0) {
|
||||
if (existsCount > 0 && successCount > 0) {
|
||||
toast.success(`${successCount} images downloaded, ${existsCount} already existed`);
|
||||
}
|
||||
else if (existsCount > 0) {
|
||||
toast.info(`All ${existsCount} images already exist`);
|
||||
}
|
||||
else {
|
||||
toast.success(`All ${successCount} gallery images downloaded successfully`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
toast.error(`${failCount} images failed to download`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(`Error downloading gallery images: ${error}`);
|
||||
}
|
||||
finally {
|
||||
setDownloadingAllGallery(false);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<Card className="overflow-hidden p-0">
|
||||
{artistInfo.header ? (<>
|
||||
<div className="relative w-full h-64 bg-cover bg-center">
|
||||
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||
{downloadingHeader ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Header</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="relative px-6 pt-6 pb-20">
|
||||
<div className="flex gap-6 items-start">
|
||||
{artistInfo.images && (<div className="relative group">
|
||||
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Avatar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium text-white/80">Artist</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-400 shrink-0"/>)}
|
||||
</div>
|
||||
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||
{artistInfo.listeners && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
||||
</>)}
|
||||
{artistInfo.rank && (<>
|
||||
<span>•</span>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
</>)}
|
||||
<span>•</span>
|
||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
{artistInfo.genres.length > 0 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>) : (<CardContent className="px-6 py-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{artistInfo.images && (<div className="relative group">
|
||||
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Avatar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium">Artist</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-500 shrink-0"/>)}
|
||||
</div>
|
||||
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||
{artistInfo.listeners && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
||||
</>)}
|
||||
{artistInfo.rank && (<>
|
||||
<span>•</span>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
</>)}
|
||||
<span>•</span>
|
||||
<span>{albumList.length} albums</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} tracks</span>
|
||||
{artistInfo.genres.length > 0 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
|
||||
{albumList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||
{downloadingAllGallery ? <Spinner className="h-4 w-4"/> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Gallery</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{artistInfo.gallery.map((imageUrl, index) => (<div key={index} className="relative group">
|
||||
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
|
||||
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => handleDownloadGalleryImage(imageUrl, index)} size="sm" variant="secondary" disabled={downloadingGalleryIndex === index} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||
{downloadingGalleryIndex === index ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Image {index + 1}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{albumList.length > 0 && (<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold">Discography</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{albumList.map((album) => (
|
||||
<div
|
||||
key={album.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
onAlbumClick({
|
||||
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
external_urls: album.external_urls,
|
||||
})
|
||||
}
|
||||
>
|
||||
})}>
|
||||
<div className="relative mb-4">
|
||||
{album.images && (
|
||||
<img
|
||||
src={album.images}
|
||||
alt={album.name}
|
||||
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
|
||||
/>
|
||||
)}
|
||||
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
||||
</div>
|
||||
<h4 className="font-semibold truncate">{album.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{album.release_date?.split("-")[0]} • {album.album_type}
|
||||
{album.release_date?.split("-")[0]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{trackList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{trackList.length > 0 && (<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
||||
<h3 className="text-2xl font-bold">All Tracks</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllLyrics} size="sm" variant="outline" disabled={isBulkDownloadingLyrics}>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Lyrics</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{onDownloadAllCovers && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllCovers} size="sm" variant="outline" disabled={isBulkDownloadingCovers}>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
<SearchAndSort
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={onSearchChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
<TrackList
|
||||
tracks={trackList}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
failedTracks={failedTracks}
|
||||
skippedTracks={skippedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={false}
|
||||
folderName={artistInfo.name}
|
||||
isArtistDiscography={true}
|
||||
downloadedLyrics={downloadedLyrics}
|
||||
failedLyrics={failedLyrics}
|
||||
skippedLyrics={skippedLyrics}
|
||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
||||
availabilityMap={availabilityMap}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onDownloadCover={onDownloadCover}
|
||||
downloadedCovers={downloadedCovers}
|
||||
failedCovers={failedCovers}
|
||||
skippedCovers={skippedCovers}
|
||||
downloadingCoverTrack={downloadingCoverTrack}
|
||||
onCheckAvailability={onCheckAvailability}
|
||||
onPageChange={onPageChange}
|
||||
onAlbumClick={onAlbumClick}
|
||||
onArtistClick={onArtistClick}
|
||||
onTrackClick={onTrackClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,144 +1,109 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Activity,
|
||||
Waves,
|
||||
Radio,
|
||||
TrendingUp,
|
||||
FileAudio,
|
||||
Clock,
|
||||
Gauge,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
interface AudioAnalysisProps {
|
||||
result: AnalysisResult | null;
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
filePath?: string;
|
||||
result: AnalysisResult | null;
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export function AudioAnalysis({
|
||||
result,
|
||||
analyzing,
|
||||
onAnalyze,
|
||||
showAnalyzeButton = true,
|
||||
filePath
|
||||
}: AudioAnalysisProps) {
|
||||
if (analyzing) {
|
||||
return (
|
||||
<Card>
|
||||
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
||||
if (analyzing) {
|
||||
return (<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner />
|
||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result && showAnalyzeButton) {
|
||||
return (
|
||||
<Card>
|
||||
</Card>);
|
||||
}
|
||||
if (!result && showAnalyzeButton) {
|
||||
return (<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Activity className="h-12 w-12 text-primary" />
|
||||
<Activity className="h-12 w-12 text-primary"/>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verify the true lossless quality of downloaded files
|
||||
</p>
|
||||
</div>
|
||||
{onAnalyze && (
|
||||
<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4" />
|
||||
{onAnalyze && (<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4"/>
|
||||
Analyze Audio
|
||||
</Button>
|
||||
)}
|
||||
</Button>)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// Calculate Nyquist frequency (half of sample rate)
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
|
||||
return (
|
||||
<Card className="gap-2">
|
||||
</Card>);
|
||||
}
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toFixed(2);
|
||||
};
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0)
|
||||
return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
return (<Card className="gap-2">
|
||||
<CardHeader>
|
||||
{filePath && (
|
||||
<p className="text-sm font-mono break-all">{filePath}</p>
|
||||
)}
|
||||
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
{/* Audio Properties - Single line */}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||
<Radio className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Sample Rate:</span>
|
||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileAudio className="h-3 w-3 text-muted-foreground" />
|
||||
<FileAudio className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Bit Depth:</span>
|
||||
<span className="font-semibold">{result.bit_depth}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Waves className="h-3 w-3 text-muted-foreground" />
|
||||
<Waves className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Channels:</span>
|
||||
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<Clock className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||
<Gauge className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Nyquist:</span>
|
||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
{result.file_size > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
{result.file_size > 0 && (<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Size:</span>
|
||||
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Range - Single line */}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-muted-foreground" />
|
||||
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
||||
</div>
|
||||
@@ -156,6 +121,5 @@ export function AudioAnalysis({
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
}
|
||||
|
||||
@@ -7,149 +7,107 @@ import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
interface AudioAnalysisPageProps {
|
||||
onBack?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
await analyzeFile(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select file",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
async (_x: number, _y: number, paths: string[]) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (paths.length === 0) return;
|
||||
|
||||
const filePath = paths[0];
|
||||
|
||||
if (!filePath.toLowerCase().endsWith(".flac")) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: "Please drop a FLAC file for analysis",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await analyzeFile(filePath);
|
||||
},
|
||||
[analyzeFile]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
await analyzeFile(filePath);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select file",
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||
setIsDragging(false);
|
||||
if (paths.length === 0)
|
||||
return;
|
||||
const filePath = paths[0];
|
||||
if (!filePath.toLowerCase().endsWith(".flac")) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: "Please drop a FLAC file for analysis",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await analyzeFile(filePath);
|
||||
}, [analyzeFile]);
|
||||
useEffect(() => {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
{result && (
|
||||
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Button>)}
|
||||
</div>
|
||||
|
||||
{/* File Selection */}
|
||||
{!result && !analyzing && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}}
|
||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||
>
|
||||
|
||||
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}} onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary" />
|
||||
<Upload className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your FLAC file here"
|
||||
: "Drag and drop a FLAC file here, or click the button below to select"}
|
||||
? "Drop your FLAC file here"
|
||||
: "Drag and drop a FLAC file here, or click the button below to select"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5" />
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select FLAC File
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{/* Loading State */}
|
||||
{analyzing && !result && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
|
||||
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{/* Analysis Results */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* Detailed Analysis */}
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath} />
|
||||
|
||||
{result && (<div className="space-y-4">
|
||||
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||
|
||||
{/* Spectrum Visualization */}
|
||||
{spectrumLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
||||
|
||||
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<SpectrumVisualization
|
||||
sampleRate={result.sample_rate}
|
||||
bitsPerSample={result.bits_per_sample}
|
||||
duration={result.duration}
|
||||
spectrumData={result.spectrum}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,100 +2,72 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { Trash2, Copy, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logger, type LogEntry } from "@/lib/logger";
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: "text-blue-500",
|
||||
success: "text-green-500",
|
||||
warning: "text-yellow-500",
|
||||
error: "text-red-500",
|
||||
debug: "text-gray-500",
|
||||
info: "text-blue-500",
|
||||
success: "text-green-500",
|
||||
warning: "text-yellow-500",
|
||||
error: "text-red-500",
|
||||
debug: "text-gray-500",
|
||||
};
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function DebugLoggerPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.subscribe(() => {
|
||||
setLogs(logger.getLogs());
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
setLogs(logger.getLogs());
|
||||
return () => {
|
||||
unsubscribe();
|
||||
}
|
||||
export function DebugLoggerPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.subscribe(() => {
|
||||
setLogs(logger.getLogs());
|
||||
});
|
||||
setLogs(logger.getLogs());
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
const handleClear = () => {
|
||||
logger.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const handleClear = () => {
|
||||
logger.clear();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const logText = logs
|
||||
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(logText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy logs:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
const handleCopy = async () => {
|
||||
const logText = logs
|
||||
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
||||
.join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(logText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to copy logs:", err);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleCopy}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
|
||||
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleClear} disabled={logs.length === 0}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground lowercase">no logs yet...</p>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<div ref={scrollRef} className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs">
|
||||
{logs.length === 0 ? (<p className="text-muted-foreground lowercase">no logs yet...</p>) : (logs.map((log, i) => (<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
[{formatTime(log.timestamp)}]
|
||||
</span>
|
||||
@@ -103,10 +75,7 @@ export function DebugLoggerPage() {
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>)))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StopCircle } from "lucide-react";
|
||||
|
||||
interface DownloadProgressProps {
|
||||
progress: number;
|
||||
currentTrack: { name: string; artists: string } | null;
|
||||
onStop: () => void;
|
||||
progress: number;
|
||||
currentTrack: {
|
||||
name: string;
|
||||
artists: string;
|
||||
} | null;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
return (
|
||||
<div className="w-full space-y-2 mt-4">
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
return (<div className="w-full space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={clampedProgress} className="h-2 flex-1" />
|
||||
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
||||
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4" />
|
||||
<StopCircle className="h-4 w-4"/>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{clampedProgress}% -{" "}
|
||||
{currentTrack
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -2,47 +2,30 @@ import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||
import { Download, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DownloadProgressToastProps {
|
||||
onClick: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
||||
const progress = useDownloadProgress();
|
||||
const queueInfo = useDownloadQueueData();
|
||||
|
||||
// Show indicator if there are any queued or downloading items
|
||||
// Don't show for completed/failed/skipped only
|
||||
const hasActiveDownloads = queueInfo.queue.some(
|
||||
item => item.status === "queued" || item.status === "downloading"
|
||||
);
|
||||
|
||||
if (!hasActiveDownloads) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
const progress = useDownloadProgress();
|
||||
const queueInfo = useDownloadQueueData();
|
||||
const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading");
|
||||
if (!hasActiveDownloads) {
|
||||
return null;
|
||||
}
|
||||
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} />
|
||||
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
|
||||
<div className="flex flex-col min-w-[80px]">
|
||||
<p className="text-sm font-medium font-mono tabular-nums">
|
||||
{progress.mb_downloaded.toFixed(2)} MB
|
||||
</p>
|
||||
{progress.speed_mbps > 0 && (
|
||||
<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{progress.speed_mbps.toFixed(2)} MB/s
|
||||
</p>
|
||||
)}
|
||||
</p>)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,194 +1,158 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
|
||||
interface DownloadQueueProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
|
||||
new backend.DownloadQueueInfo({
|
||||
is_downloading: false,
|
||||
queue: [],
|
||||
current_speed: 0,
|
||||
total_downloaded: 0,
|
||||
session_start_time: 0,
|
||||
queued_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
skipped_count: 0,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to get download queue:", error);
|
||||
}
|
||||
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
|
||||
is_downloading: false,
|
||||
queue: [],
|
||||
current_speed: 0,
|
||||
total_downloaded: 0,
|
||||
session_start_time: 0,
|
||||
queued_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
skipped_count: 0,
|
||||
}));
|
||||
useEffect(() => {
|
||||
if (!isOpen)
|
||||
return;
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to get download queue:", error);
|
||||
}
|
||||
};
|
||||
fetchQueue();
|
||||
const interval = setInterval(fetchQueue, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen]);
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await ClearCompletedDownloads();
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to clear history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchQueue();
|
||||
|
||||
// Poll every 500ms when dialog is open
|
||||
const interval = setInterval(fetchQueue, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await ClearCompletedDownloads();
|
||||
// Refetch immediately to update UI
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "downloading":
|
||||
return <Download className="h-4 w-4 text-blue-500 animate-bounce" />;
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case "skipped":
|
||||
return <FileCheck className="h-4 w-4 text-yellow-500" />;
|
||||
case "queued":
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
downloading: "default",
|
||||
completed: "outline",
|
||||
failed: "destructive",
|
||||
skipped: "secondary",
|
||||
queued: "outline",
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "downloading":
|
||||
return <Download className="h-4 w-4 text-blue-500 animate-bounce"/>;
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
|
||||
case "failed":
|
||||
return <XCircle className="h-4 w-4 text-red-500"/>;
|
||||
case "skipped":
|
||||
return <FileCheck className="h-4 w-4 text-yellow-500"/>;
|
||||
case "queued":
|
||||
return <Clock className="h-4 w-4 text-muted-foreground"/>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
downloading: "default",
|
||||
completed: "outline",
|
||||
failed: "destructive",
|
||||
skipped: "secondary",
|
||||
queued: "outline",
|
||||
};
|
||||
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// Format session duration
|
||||
const formatDuration = (startTimestamp: number) => {
|
||||
if (startTimestamp === 0) return "—";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const durationSeconds = now - startTimestamp;
|
||||
|
||||
const hours = Math.floor(durationSeconds / 3600);
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
</Badge>);
|
||||
};
|
||||
const formatDuration = (startTimestamp: number) => {
|
||||
if (startTimestamp === 0)
|
||||
return "—";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const durationSeconds = now - startTimestamp;
|
||||
const hours = Math.floor(durationSeconds / 3600);
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
}
|
||||
else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
return (<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={handleClearHistory}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
Clear History
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full hover:bg-muted"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue Status */}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Queued:</span>
|
||||
<span className="font-semibold">{queueInfo.queued_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
||||
<span className="text-muted-foreground">Completed:</span>
|
||||
<span className="font-semibold">{queueInfo.completed_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
||||
<span className="text-muted-foreground">Skipped:</span>
|
||||
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
||||
<span className="text-muted-foreground">Failed:</span>
|
||||
<span className="font-semibold">{queueInfo.failed_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Stats */}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Downloaded:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Speed:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
||||
@@ -198,20 +162,13 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
|
||||
</DialogHeader>
|
||||
|
||||
{/* Download Queue List */}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
||||
<div className="space-y-2 py-4">
|
||||
{queueInfo.queue.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Download className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
||||
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||
<p>No downloads in queue</p>
|
||||
</div>
|
||||
) : (
|
||||
queueInfo.queue.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border rounded-lg p-3 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||
|
||||
@@ -227,61 +184,48 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
|
||||
{/* Info for downloading items */}
|
||||
{item.status === "downloading" && (
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||
|
||||
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||
<span>
|
||||
{item.progress > 0
|
||||
? `${item.progress.toFixed(2)} MB`
|
||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||
? "Downloading..."
|
||||
: "Starting..."}
|
||||
? `${item.progress.toFixed(2)} MB`
|
||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||
? "Downloading..."
|
||||
: "Starting..."}
|
||||
</span>
|
||||
<span>
|
||||
{item.speed > 0
|
||||
? `${item.speed.toFixed(2)} MB/s`
|
||||
: queueInfo.current_speed > 0
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
? `${item.speed.toFixed(2)} MB/s`
|
||||
: queueInfo.current_speed > 0
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{/* Completed info */}
|
||||
{item.status === "completed" && (
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||
|
||||
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{/* Skipped info */}
|
||||
{item.status === "skipped" && (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
|
||||
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
File already exists
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{/* Error message */}
|
||||
{item.status === "failed" && item.error_message && (
|
||||
<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
||||
|
||||
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
||||
{item.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{/* File path for completed/skipped */}
|
||||
{(item.status === "completed" || item.status === "skipped") && item.file_path && (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
||||
|
||||
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
||||
{item.file_path}
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>)))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
</Dialog>);
|
||||
}
|
||||
|
||||
@@ -1,91 +1,96 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { X, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: "track" | "album" | "playlist" | "artist";
|
||||
name: string;
|
||||
artist: string;
|
||||
image: string;
|
||||
timestamp: number;
|
||||
id: string;
|
||||
url: string;
|
||||
type: "track" | "album" | "playlist" | "artist";
|
||||
name: string;
|
||||
artist: string;
|
||||
image: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FetchHistoryProps {
|
||||
history: HistoryItem[];
|
||||
onSelect: (item: HistoryItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
history: HistoryItem[];
|
||||
onSelect: (item: HistoryItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
|
||||
if (history.length === 0) return null;
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
return "Track";
|
||||
case "album":
|
||||
return "Album";
|
||||
case "playlist":
|
||||
return "Playlist";
|
||||
case "artist":
|
||||
return "Artist";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm text-muted-foreground">Recent Fetches</span>
|
||||
if (history.length === 0)
|
||||
return null;
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
return "Track";
|
||||
case "album":
|
||||
return "Album";
|
||||
case "playlist":
|
||||
return "Playlist";
|
||||
case "artist":
|
||||
return "Artist";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
return Music2;
|
||||
case "album":
|
||||
return Disc3;
|
||||
case "playlist":
|
||||
return ListMusic;
|
||||
case "artist":
|
||||
return UserRound;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const getTypeBadgeClass = (type: string) => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
return "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400";
|
||||
case "album":
|
||||
return "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400";
|
||||
case "playlist":
|
||||
return "bg-purple-500/10 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400";
|
||||
case "artist":
|
||||
return "bg-orange-500/10 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-2">
|
||||
<span className="text-sm text-muted-foreground">{history.length === 1 ? "Recent Fetch" : "Recent Fetches"}</span>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
|
||||
{history.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible"
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
|
||||
onClick={(e) => {
|
||||
{history.map((item) => (<div key={item.id} className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible" onClick={() => onSelect(item)}>
|
||||
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(item.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
|
||||
}}>
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||
</button>
|
||||
<div className="p-2">
|
||||
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
|
||||
{item.image ? (<img src={item.image} alt={item.name} className="w-full h-full object-cover"/>) : (<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={item.artist}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
|
||||
{item.artist}
|
||||
</p>
|
||||
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{getTypeLabel(item.type)}
|
||||
</span>
|
||||
{(() => {
|
||||
const IconComponent = getTypeIcon(item.type);
|
||||
return (<span className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded ${getTypeBadgeClass(item.type)}`}>
|
||||
{IconComponent ? <IconComponent className="h-2.5 w-2.5"/> : null}
|
||||
{getTypeLabel(item.type)}
|
||||
</span>);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +1,42 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/relative-time";
|
||||
|
||||
interface HeaderProps {
|
||||
version: string;
|
||||
hasUpdate: boolean;
|
||||
releaseDate?: string | null;
|
||||
version: string;
|
||||
hasUpdate: boolean;
|
||||
releaseDate?: string | null;
|
||||
}
|
||||
|
||||
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
return (<div className="relative">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<img
|
||||
src="/icon.svg"
|
||||
alt="SpotiFLAC"
|
||||
className="w-12 h-12 cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
/>
|
||||
<h1
|
||||
className="text-4xl font-bold cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
|
||||
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
|
||||
SpotiFLAC
|
||||
</h1>
|
||||
<div className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="default" asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<button type="button" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")} className="cursor-pointer hover:opacity-80 transition-opacity">
|
||||
v{version}
|
||||
</button>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{hasUpdate && releaseDate && (
|
||||
<TooltipContent>
|
||||
{hasUpdate && releaseDate && (<TooltipContent>
|
||||
<p>{formatRelativeTime(releaseDate)}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</TooltipContent>)}
|
||||
</Tooltip>
|
||||
{hasUpdate && (
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
{hasUpdate && (<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
// Platform Icons for streaming services
|
||||
|
||||
export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
export const TidalIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
</svg>);
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
|
||||
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
</svg>);
|
||||
export const AmazonIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<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>
|
||||
);
|
||||
</svg>);
|
||||
|
||||
@@ -7,135 +7,94 @@ import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
|
||||
interface PlaylistInfoProps {
|
||||
playlistInfo: {
|
||||
owner: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
images: string;
|
||||
playlistInfo: {
|
||||
owner: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
images: string;
|
||||
};
|
||||
tracks: {
|
||||
total: number;
|
||||
};
|
||||
followers: {
|
||||
total: number;
|
||||
};
|
||||
cover?: string;
|
||||
description?: string;
|
||||
};
|
||||
tracks: {
|
||||
total: number;
|
||||
};
|
||||
followers: {
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onTrackClick: (track: TrackMetadata) => void;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
} | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllLyrics?: () => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAlbumClick: (album: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onArtistClick: (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onTrackClick: (track: TrackMetadata) => 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,
|
||||
}: PlaylistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
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, }: PlaylistInfoProps) {
|
||||
return (<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{playlistInfo.owner.images && (
|
||||
<img
|
||||
src={playlistInfo.owner.images}
|
||||
alt={playlistInfo.owner.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Playlist</p>
|
||||
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
|
||||
{playlistInfo.description && (<p className="text-sm text-muted-foreground">{playlistInfo.description}</p>)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{playlistInfo.owner.images && (<img src={playlistInfo.owner.images} alt={playlistInfo.owner.display_name} className="w-5 h-5 rounded-full object-cover"/>)}
|
||||
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
||||
@@ -146,121 +105,46 @@ export function PlaylistInfo({
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
|
||||
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Lyrics</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{onDownloadAllCovers && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</Button>)}
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<SearchAndSort
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={onSearchChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
<TrackList
|
||||
tracks={trackList}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
failedTracks={failedTracks}
|
||||
skippedTracks={skippedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={false}
|
||||
folderName={playlistInfo.owner.name}
|
||||
downloadedLyrics={downloadedLyrics}
|
||||
failedLyrics={failedLyrics}
|
||||
skippedLyrics={skippedLyrics}
|
||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
||||
availabilityMap={availabilityMap}
|
||||
downloadedCovers={downloadedCovers}
|
||||
failedCovers={failedCovers}
|
||||
skippedCovers={skippedCovers}
|
||||
downloadingCoverTrack={downloadingCoverTrack}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onDownloadCover={onDownloadCover}
|
||||
onCheckAvailability={onCheckAvailability}
|
||||
onPageChange={onPageChange}
|
||||
onAlbumClick={onAlbumClick}
|
||||
onArtistClick={onArtistClick}
|
||||
onTrackClick={onTrackClick}
|
||||
/>
|
||||
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={playlistInfo.owner.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Search, ArrowUpDown, XCircle } from "lucide-react";
|
||||
|
||||
interface SearchAndSortProps {
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchAndSort({
|
||||
searchQuery,
|
||||
sortBy,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
}: SearchAndSortProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) {
|
||||
return (<div className="flex gap-2">
|
||||
<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 tracks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10 pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<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={() => onSearchChange("")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input placeholder="Search tracks..." value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} className="pl-10 pr-8"/>
|
||||
{searchQuery && (<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={() => onSearchChange("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</div>
|
||||
<Select value={sortBy} onValueChange={onSortChange}>
|
||||
<SelectTrigger className="w-[200px] gap-1.5">
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
<ArrowUpDown className="h-4 w-4"/>
|
||||
<SelectValue placeholder="Sort by"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
@@ -54,10 +29,11 @@ export function SearchAndSort({
|
||||
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
|
||||
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
|
||||
<SelectItem value="plays-asc">Plays (Low)</SelectItem>
|
||||
<SelectItem value="plays-desc">Plays (High)</SelectItem>
|
||||
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -3,447 +3,316 @@ import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FetchHistory } from "@/components/FetchHistory";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
|
||||
|
||||
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
|
||||
const MAX_RECENT_SEARCHES = 8;
|
||||
const SEARCH_LIMIT = 50;
|
||||
|
||||
interface SearchBarProps {
|
||||
url: string;
|
||||
loading: boolean;
|
||||
onUrlChange: (url: string) => void;
|
||||
onFetch: () => void;
|
||||
onFetchUrl: (url: string) => Promise<void>;
|
||||
history: HistoryItem[];
|
||||
onHistorySelect: (item: HistoryItem) => void;
|
||||
onHistoryRemove: (id: string) => void;
|
||||
hasResult: boolean;
|
||||
searchMode: boolean;
|
||||
onSearchModeChange: (isSearch: boolean) => void;
|
||||
url: string;
|
||||
loading: boolean;
|
||||
onUrlChange: (url: string) => void;
|
||||
onFetch: () => void;
|
||||
onFetchUrl: (url: string) => Promise<void>;
|
||||
history: HistoryItem[];
|
||||
onHistorySelect: (item: HistoryItem) => void;
|
||||
onHistoryRemove: (id: string) => void;
|
||||
hasResult: boolean;
|
||||
searchMode: boolean;
|
||||
onSearchModeChange: (isSearch: boolean) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
url,
|
||||
loading,
|
||||
onUrlChange,
|
||||
onFetch,
|
||||
onFetchUrl,
|
||||
history,
|
||||
onHistorySelect,
|
||||
onHistoryRemove,
|
||||
hasResult,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
}: SearchBarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
|
||||
tracks: false,
|
||||
albums: false,
|
||||
artists: false,
|
||||
playlists: false,
|
||||
});
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Load recent searches from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||
if (saved) {
|
||||
setRecentSearches(JSON.parse(saved));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load recent searches:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveRecentSearch = (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setRecentSearches((prev) => {
|
||||
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
|
||||
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
|
||||
try {
|
||||
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error("Failed to save recent searches:", error);
|
||||
}
|
||||
return updated;
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
|
||||
tracks: false,
|
||||
albums: false,
|
||||
artists: false,
|
||||
playlists: false,
|
||||
});
|
||||
};
|
||||
|
||||
const removeRecentSearch = (query: string) => {
|
||||
setRecentSearches((prev) => {
|
||||
const updated = prev.filter((s) => s !== query);
|
||||
try {
|
||||
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error("Failed to save recent searches:", error);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Debounced search - only search if query changed
|
||||
useEffect(() => {
|
||||
if (!searchMode || !searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't search again if query is the same
|
||||
if (searchQuery.trim() === lastSearchedQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
|
||||
setSearchResults(results);
|
||||
setLastSearchedQuery(searchQuery.trim());
|
||||
saveRecentSearch(searchQuery.trim());
|
||||
|
||||
// Check if there might be more results
|
||||
setHasMore({
|
||||
tracks: results.tracks.length === SEARCH_LIMIT,
|
||||
albums: results.albums.length === SEARCH_LIMIT,
|
||||
artists: results.artists.length === SEARCH_LIMIT,
|
||||
playlists: results.playlists.length === SEARCH_LIMIT,
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||
if (saved) {
|
||||
setRecentSearches(JSON.parse(saved));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to load recent searches:", error);
|
||||
}
|
||||
}, []);
|
||||
const saveRecentSearch = (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed)
|
||||
return;
|
||||
setRecentSearches((prev) => {
|
||||
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
|
||||
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
|
||||
try {
|
||||
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to save recent searches:", error);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Auto-select first tab with results
|
||||
if (results.tracks.length > 0) setActiveTab("tracks");
|
||||
else if (results.albums.length > 0) setActiveTab("albums");
|
||||
else if (results.artists.length > 0) setActiveTab("artists");
|
||||
else if (results.playlists.length > 0) setActiveTab("playlists");
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
setSearchResults(null);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, searchMode, lastSearchedQuery]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!searchResults || !lastSearchedQuery || isLoadingMore) return;
|
||||
|
||||
const typeMap: Record<ResultTab, string> = {
|
||||
tracks: "track",
|
||||
albums: "album",
|
||||
artists: "artist",
|
||||
playlists: "playlist",
|
||||
};
|
||||
|
||||
const currentCount = getTabCount(activeTab);
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const moreResults = await SearchSpotifyByType({
|
||||
query: lastSearchedQuery,
|
||||
search_type: typeMap[activeTab],
|
||||
limit: SEARCH_LIMIT,
|
||||
offset: currentCount,
|
||||
});
|
||||
|
||||
if (moreResults.length > 0) {
|
||||
setSearchResults((prev) => {
|
||||
if (!prev) return prev;
|
||||
// Create new SearchResponse with updated array for the active tab
|
||||
const updated = new backend.SearchResponse({
|
||||
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
|
||||
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
|
||||
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
|
||||
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
|
||||
});
|
||||
return updated;
|
||||
const removeRecentSearch = (query: string) => {
|
||||
setRecentSearches((prev) => {
|
||||
const updated = prev.filter((s) => s !== query);
|
||||
try {
|
||||
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to save recent searches:", error);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Update hasMore for this tab
|
||||
setHasMore((prev) => ({
|
||||
...prev,
|
||||
[activeTab]: moreResults.length === SEARCH_LIMIT,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Load more failed:", error);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResultClick = (externalUrl: string) => {
|
||||
onSearchModeChange(false);
|
||||
onFetchUrl(externalUrl);
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const hasAnyResults = searchResults && (
|
||||
searchResults.tracks.length > 0 ||
|
||||
searchResults.albums.length > 0 ||
|
||||
searchResults.artists.length > 0 ||
|
||||
searchResults.playlists.length > 0
|
||||
);
|
||||
|
||||
const getTabCount = (tab: ResultTab): number => {
|
||||
if (!searchResults) return 0;
|
||||
switch (tab) {
|
||||
case "tracks": return searchResults.tracks.length;
|
||||
case "albums": return searchResults.albums.length;
|
||||
case "artists": return searchResults.artists.length;
|
||||
case "playlists": return searchResults.playlists.length;
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { key: ResultTab; label: string }[] = [
|
||||
{ key: "tracks", label: "Tracks" },
|
||||
{ key: "albums", label: "Albums" },
|
||||
{ key: "artists", label: "Artists" },
|
||||
{ key: "playlists", label: "Playlists" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!searchMode || !searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
if (searchQuery.trim() === lastSearchedQuery) {
|
||||
return;
|
||||
}
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
|
||||
setSearchResults(results);
|
||||
setLastSearchedQuery(searchQuery.trim());
|
||||
saveRecentSearch(searchQuery.trim());
|
||||
setHasMore({
|
||||
tracks: results.tracks.length === SEARCH_LIMIT,
|
||||
albums: results.albums.length === SEARCH_LIMIT,
|
||||
artists: results.artists.length === SEARCH_LIMIT,
|
||||
playlists: results.playlists.length === SEARCH_LIMIT,
|
||||
});
|
||||
if (results.tracks.length > 0)
|
||||
setActiveTab("tracks");
|
||||
else if (results.albums.length > 0)
|
||||
setActiveTab("albums");
|
||||
else if (results.artists.length > 0)
|
||||
setActiveTab("artists");
|
||||
else if (results.playlists.length > 0)
|
||||
setActiveTab("playlists");
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
setSearchResults(null);
|
||||
}
|
||||
finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, searchMode, lastSearchedQuery]);
|
||||
const handleLoadMore = async () => {
|
||||
if (!searchResults || !lastSearchedQuery || isLoadingMore)
|
||||
return;
|
||||
const typeMap: Record<ResultTab, string> = {
|
||||
tracks: "track",
|
||||
albums: "album",
|
||||
artists: "artist",
|
||||
playlists: "playlist",
|
||||
};
|
||||
const currentCount = getTabCount(activeTab);
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const moreResults = await SearchSpotifyByType({
|
||||
query: lastSearchedQuery,
|
||||
search_type: typeMap[activeTab],
|
||||
limit: SEARCH_LIMIT,
|
||||
offset: currentCount,
|
||||
});
|
||||
if (moreResults.length > 0) {
|
||||
setSearchResults((prev) => {
|
||||
if (!prev)
|
||||
return prev;
|
||||
const updated = new backend.SearchResponse({
|
||||
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
|
||||
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
|
||||
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
|
||||
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
setHasMore((prev) => ({
|
||||
...prev,
|
||||
[activeTab]: moreResults.length === SEARCH_LIMIT,
|
||||
}));
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Load more failed:", error);
|
||||
}
|
||||
finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
const handleResultClick = (externalUrl: string) => {
|
||||
onSearchModeChange(false);
|
||||
onFetchUrl(externalUrl);
|
||||
};
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 ||
|
||||
searchResults.albums.length > 0 ||
|
||||
searchResults.artists.length > 0 ||
|
||||
searchResults.playlists.length > 0);
|
||||
const getTabCount = (tab: ResultTab): number => {
|
||||
if (!searchResults)
|
||||
return 0;
|
||||
switch (tab) {
|
||||
case "tracks": return searchResults.tracks.length;
|
||||
case "albums": return searchResults.albums.length;
|
||||
case "artists": return searchResults.artists.length;
|
||||
case "playlists": return searchResults.playlists.length;
|
||||
}
|
||||
};
|
||||
const tabs: {
|
||||
key: ResultTab;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "tracks", label: "Tracks" },
|
||||
{ key: "albums", label: "Albums" },
|
||||
{ key: "artists", label: "Artists" },
|
||||
{ key: "playlists", label: "Playlists" },
|
||||
];
|
||||
return (<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode Toggle */}
|
||||
|
||||
<div className="flex items-center bg-muted rounded-md p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchModeChange(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
|
||||
!searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Link className="h-3.5 w-3.5" />
|
||||
<button type="button" onClick={() => onSearchModeChange(false)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", !searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")}>
|
||||
<Link className="h-3.5 w-3.5"/>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchModeChange(true)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
|
||||
searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<button type="button" onClick={() => onSearchModeChange(true)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")}>
|
||||
<Search className="h-3.5 w-3.5"/>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{!searchMode ? (
|
||||
<>
|
||||
{!searchMode ? (<>
|
||||
<p>Supports track, album, playlist, and artist URLs</p>
|
||||
<p className="mt-1">Note: Playlist must be public (not private)</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Search for tracks, albums, artists, or playlists</p>
|
||||
)}
|
||||
</>) : (<p>Search for tracks, albums, artists, or playlists</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
{!searchMode ? (
|
||||
<>
|
||||
<InputWithContext
|
||||
id="spotify-url"
|
||||
placeholder="https://open.spotify.com/..."
|
||||
value={url}
|
||||
onChange={(e) => onUrlChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onFetch()}
|
||||
className="pr-8"
|
||||
/>
|
||||
{url && (
|
||||
<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={() => onUrlChange("")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InputWithContext
|
||||
id="spotify-search"
|
||||
placeholder="Search tracks, albums, artists..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<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={() => {
|
||||
setSearchQuery("");
|
||||
setSearchResults(null);
|
||||
setLastSearchedQuery("");
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!searchMode ? (<>
|
||||
<InputWithContext id="spotify-url" placeholder="https://open.spotify.com/..." value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
|
||||
{url && (<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={() => onUrlChange("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>) : (<>
|
||||
<InputWithContext id="spotify-search" placeholder="Search tracks, albums, artists..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
||||
{searchQuery && (<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={() => {
|
||||
setSearchQuery("");
|
||||
setSearchResults(null);
|
||||
setLastSearchedQuery("");
|
||||
}}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{!searchMode && (
|
||||
<Button onClick={onFetch} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
{!searchMode && (<Button onClick={onFetch} disabled={loading}>
|
||||
{loading ? (<>
|
||||
<Spinner />
|
||||
Fetching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudDownload className="h-4 w-4" />
|
||||
</>) : (<>
|
||||
<CloudDownload className="h-4 w-4"/>
|
||||
Fetch
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>)}
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!searchMode && !hasResult && (
|
||||
<FetchHistory
|
||||
history={history}
|
||||
onSelect={onHistorySelect}
|
||||
onRemove={onHistoryRemove}
|
||||
/>
|
||||
)}
|
||||
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
||||
|
||||
{/* Search Results with Tabs */}
|
||||
{searchMode && (
|
||||
<div className="space-y-4">
|
||||
{/* Recent Searches - show when no query or no results yet */}
|
||||
{!searchQuery && !searchResults && recentSearches.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
|
||||
{searchMode && (<div className="space-y-4">
|
||||
|
||||
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((query) => (
|
||||
<div
|
||||
key={query}
|
||||
className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors"
|
||||
onClick={() => setSearchQuery(query)}
|
||||
>
|
||||
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
|
||||
<span>{query}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
|
||||
onClick={(e) => {
|
||||
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRecentSearch(query);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
|
||||
}}>
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{isSearching && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
{isSearching && (<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
<span className="ml-2 text-muted-foreground">Searching...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{!isSearching && searchQuery && !hasAnyResults && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
||||
No results found for "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>)}
|
||||
|
||||
{!isSearching && hasAnyResults && (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
{!isSearching && hasAnyResults && (<>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const count = getTabCount(tab.key);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px",
|
||||
activeTab === tab.key
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
const count = getTabCount(tab.key);
|
||||
if (count === 0)
|
||||
return null;
|
||||
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
||||
{tab.label} ({count})
|
||||
</button>
|
||||
);
|
||||
</button>);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
|
||||
<div className="grid gap-2">
|
||||
{/* Tracks */}
|
||||
{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)}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
||||
{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)}>
|
||||
{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">
|
||||
<p className="font-medium truncate">{track.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
|
||||
@@ -451,101 +320,54 @@ export function SearchBar({
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{formatDuration(track.duration_ms || 0)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</button>))}
|
||||
|
||||
{/* Albums */}
|
||||
{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)}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
||||
{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)}>
|
||||
{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>
|
||||
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{album.total_tracks} tracks
|
||||
{album.release_date || ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</button>))}
|
||||
|
||||
{/* Artists */}
|
||||
{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)}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
||||
{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)}>
|
||||
{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>
|
||||
<p className="text-sm text-muted-foreground">Artist</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</button>))}
|
||||
|
||||
{/* Playlists */}
|
||||
{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)}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
||||
{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)}>
|
||||
{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>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{playlist.owner} • {playlist.total_tracks} tracks
|
||||
{playlist.owner || ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</button>))}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasMore[activeTab] && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
|
||||
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? (<>
|
||||
<Spinner />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</>) : (<>
|
||||
<ChevronDown className="h-4 w-4"/>
|
||||
Load More
|
||||
</>
|
||||
)}
|
||||
</>)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</div>)}
|
||||
</>)}
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,159 +1,138 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { 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 { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
|
||||
// Service Icons
|
||||
const TidalIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const QobuzIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
</svg>);
|
||||
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AmazonIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
</svg>);
|
||||
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function SettingsPage() {
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (savedSettings.themeMode === "auto") {
|
||||
applyThemeMode("auto");
|
||||
</svg>);
|
||||
interface SettingsPageProps {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
}
|
||||
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||
const resetToSaved = useCallback(() => {
|
||||
const freshSavedSettings = getSettings();
|
||||
flushSync(() => {
|
||||
setTempSettings(freshSavedSettings);
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (onResetRequest) {
|
||||
onResetRequest(resetToSaved);
|
||||
}
|
||||
}, [onResetRequest, resetToSaved]);
|
||||
useEffect(() => {
|
||||
onUnsavedChangesChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onUnsavedChangesChange]);
|
||||
useEffect(() => {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
}
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (savedSettings.themeMode === "auto") {
|
||||
applyThemeMode("auto");
|
||||
applyTheme(savedSettings.theme);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [savedSettings.themeMode, savedSettings.theme]);
|
||||
useEffect(() => {
|
||||
applyThemeMode(tempSettings.themeMode);
|
||||
applyTheme(tempSettings.theme);
|
||||
applyFont(tempSettings.fontFamily);
|
||||
setTimeout(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
}, 0);
|
||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||
useEffect(() => {
|
||||
const loadDefaults = async () => {
|
||||
if (!savedSettings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
setSavedSettings(settingsWithDefaults);
|
||||
setTempSettings(settingsWithDefaults);
|
||||
saveSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
loadDefaults();
|
||||
}, []);
|
||||
const handleSave = () => {
|
||||
saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
toast.success("Settings saved");
|
||||
onUnsavedChangesChange?.(false);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [savedSettings.themeMode, savedSettings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(tempSettings.themeMode);
|
||||
applyTheme(tempSettings.theme);
|
||||
applyFont(tempSettings.fontFamily);
|
||||
setTimeout(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
}, 0);
|
||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefaults = async () => {
|
||||
if (!savedSettings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
setSavedSettings(settingsWithDefaults);
|
||||
setTempSettings(settingsWithDefaults);
|
||||
// Save to localStorage so it persists on reload
|
||||
saveSettings(settingsWithDefaults);
|
||||
}
|
||||
const handleReset = async () => {
|
||||
const defaultSettings = await resetToDefaultSettings();
|
||||
setTempSettings(defaultSettings);
|
||||
setSavedSettings(defaultSettings);
|
||||
applyThemeMode(defaultSettings.themeMode);
|
||||
applyTheme(defaultSettings.theme);
|
||||
applyFont(defaultSettings.fontFamily);
|
||||
setShowResetConfirm(false);
|
||||
toast.success("Settings reset to default");
|
||||
};
|
||||
loadDefaults();
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
toast.success("Settings saved");
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
const defaultSettings = await resetToDefaultSettings();
|
||||
setTempSettings(defaultSettings);
|
||||
setSavedSettings(defaultSettings);
|
||||
applyThemeMode(defaultSettings.themeMode);
|
||||
applyTheme(defaultSettings.theme);
|
||||
applyFont(defaultSettings.fontFamily);
|
||||
setShowResetConfirm(false);
|
||||
toast.success("Settings reset to default");
|
||||
};
|
||||
|
||||
const handleBrowseFolder = async () => {
|
||||
try {
|
||||
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||
if (selectedPath && selectedPath.trim() !== "") {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting folder:", error);
|
||||
toast.error(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
const handleBrowseFolder = async () => {
|
||||
try {
|
||||
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||
if (selectedPath && selectedPath.trim() !== "") {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error selecting folder:", error);
|
||||
toast.error(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left Column */}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Download Path */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<InputWithContext
|
||||
id="download-path"
|
||||
value={tempSettings.downloadPath}
|
||||
onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))}
|
||||
placeholder="C:\Users\YourUsername\Music"
|
||||
/>
|
||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Mode */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select
|
||||
value={tempSettings.themeMode}
|
||||
onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}
|
||||
>
|
||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode" />
|
||||
<SelectValue placeholder="Select theme mode"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
@@ -163,77 +142,57 @@ export function SettingsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Accent */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Accent</Label>
|
||||
<Select
|
||||
value={tempSettings.theme}
|
||||
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
|
||||
>
|
||||
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue placeholder="Select a theme" />
|
||||
<SelectValue placeholder="Select a theme"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{themes.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.name}>
|
||||
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border"
|
||||
style={{
|
||||
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
||||
}}
|
||||
/>
|
||||
<span className="w-3 h-3 rounded-full border border-border" style={{
|
||||
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
||||
}}/>
|
||||
{theme.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font">Font</Label>
|
||||
<Select
|
||||
value={tempSettings.fontFamily}
|
||||
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
|
||||
>
|
||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||
<SelectTrigger id="font">
|
||||
<SelectValue placeholder="Select a font" />
|
||||
<SelectValue placeholder="Select a font"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (
|
||||
<SelectItem key={font.value} value={font.value}>
|
||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sound Effects */}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||
<Switch
|
||||
id="sfx-enabled"
|
||||
checked={tempSettings.sfxEnabled}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||
/>
|
||||
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Source Selection */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={tempSettings.downloader}
|
||||
onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
|
||||
>
|
||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
||||
<SelectTrigger id="downloader" className="h-9 w-fit">
|
||||
<SelectValue placeholder="Select a source" />
|
||||
<SelectValue placeholder="Select a source"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
@@ -248,12 +207,8 @@ export function SettingsPage() {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Quality dropdown for Tidal */}
|
||||
{tempSettings.downloader === "tidal" && (
|
||||
<Select
|
||||
value={tempSettings.tidalQuality}
|
||||
onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}
|
||||
>
|
||||
|
||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -261,14 +216,9 @@ export function SettingsPage() {
|
||||
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* Quality dropdown for Qobuz */}
|
||||
{tempSettings.downloader === "qobuz" && (
|
||||
<Select
|
||||
value={tempSettings.qobuzQuality}
|
||||
onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}
|
||||
>
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -277,40 +227,40 @@ export function SettingsPage() {
|
||||
<SelectItem value="7">FLAC 24-bit</SelectItem>
|
||||
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HI_RES">Hi-Res (24-bit/96kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embed Lyrics & Embed Max Quality Cover */}
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
||||
<Switch
|
||||
id="embed-lyrics"
|
||||
checked={tempSettings.embedLyrics}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}
|
||||
/>
|
||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
|
||||
<Switch
|
||||
id="embed-max-quality-cover"
|
||||
checked={tempSettings.embedMaxQualityCover}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}
|
||||
/>
|
||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
<div className="border-t"/>
|
||||
|
||||
{/* Folder Structure */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
@@ -318,51 +268,37 @@ export function SettingsPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={tempSettings.folderPreset}
|
||||
onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.folderTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
||||
placeholder="{artist}/{album}"
|
||||
className="h-9 text-sm flex-1"
|
||||
/>
|
||||
)}
|
||||
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
|
||||
</div>
|
||||
{tempSettings.folderTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
|
||||
</p>
|
||||
)}
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
<div className="border-t"/>
|
||||
|
||||
{/* Filename Format */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
@@ -370,57 +306,43 @@ export function SettingsPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={tempSettings.filenamePreset}
|
||||
onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.filenameTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
||||
placeholder="{track}. {title}"
|
||||
className="h-9 text-sm flex-1"
|
||||
/>
|
||||
)}
|
||||
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||
</div>
|
||||
{tempSettings.filenameTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||
</p>
|
||||
)}
|
||||
</p>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
<div className="flex gap-2 justify-between pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<RotateCcw className="h-4 w-4"/>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4" />
|
||||
<Save className="h-4 w-4"/>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reset Confirmation Dialog */}
|
||||
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
@@ -435,6 +357,5 @@ export function SettingsPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,28 @@
|
||||
import { FileMusic, FilePen } from "lucide-react";
|
||||
import { HomeIcon } from "@/components/ui/home";
|
||||
import { SettingsIcon } from "@/components/ui/settings";
|
||||
import { ActivityIcon } from "@/components/ui/activity";
|
||||
import { TerminalIcon } from "@/components/ui/terminal";
|
||||
import { FileMusicIcon } from "@/components/ui/file-music";
|
||||
import { FilePenIcon } from "@/components/ui/file-pen";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks";
|
||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
|
||||
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
{/* Home */}
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "main" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("main")}
|
||||
>
|
||||
<HomeIcon size={20} />
|
||||
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
||||
<HomeIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
@@ -42,16 +30,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings */}
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "settings" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("settings")}
|
||||
>
|
||||
<SettingsIcon 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">
|
||||
@@ -59,16 +42,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Audio Analysis */}
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-analysis")}
|
||||
>
|
||||
<ActivityIcon size={20} />
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
@@ -76,16 +54,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Audio Converter - using lucide icon (no animated version) */}
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-converter" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-converter")}
|
||||
>
|
||||
<FileMusic className="h-5 w-5" />
|
||||
<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">
|
||||
@@ -93,16 +66,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* File Manager - using lucide icon (no animated version) */}
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "file-manager" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("file-manager")}
|
||||
>
|
||||
<FilePen className="h-5 w-5" />
|
||||
<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">
|
||||
@@ -110,16 +78,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Debug */}
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "debug" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("debug")}
|
||||
>
|
||||
<TerminalIcon size={20} />
|
||||
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
||||
<TerminalIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
@@ -128,17 +91,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Bottom icons */}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||
>
|
||||
<GithubIcon size={20} />
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?labels=bug&body=%23%23%23%20Problem%0AExplain%20the%20issue%20briefly.%0A%0A%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%20Spotify%20URL%0APaste%20the%20link%20here.%0A%0A%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS")}>
|
||||
<GithubIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
@@ -147,13 +105,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://exyezed.cc/")}
|
||||
>
|
||||
<BlocksIcon size={20} />
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://exyezed.cc/")}>
|
||||
<BlocksIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
@@ -162,20 +115,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
|
||||
>
|
||||
<CoffeeIcon size={20} />
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<CoffeeIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Support me on Ko-fi</p>
|
||||
<p>Every coffee helps me keep going</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,289 +1,193 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
bitsPerSample: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
sampleRate: number;
|
||||
bitsPerSample: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
}
|
||||
|
||||
export function SpectrumVisualization({
|
||||
sampleRate,
|
||||
bitsPerSample,
|
||||
duration,
|
||||
spectrumData,
|
||||
}: SpectrumVisualizationProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Calculate margins for labels
|
||||
const marginLeft = 70; // More space for Frequency label
|
||||
const marginRight = 70; // Space for color bar
|
||||
const marginTop = 30; // More space at top
|
||||
const marginBottom = 65; // More space at bottom for Time label
|
||||
|
||||
const plotWidth = width - marginLeft - marginRight;
|
||||
const plotHeight = height - marginTop - marginBottom;
|
||||
|
||||
// Black background
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Calculate Nyquist frequency
|
||||
const nyquistFreq = sampleRate / 2;
|
||||
|
||||
if (spectrumData) {
|
||||
drawRealSpectrum(
|
||||
ctx,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
plotWidth,
|
||||
plotHeight,
|
||||
spectrumData
|
||||
);
|
||||
}
|
||||
|
||||
// Draw axes, labels, and color bar
|
||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||
|
||||
const drawRealSpectrum = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
spectrum: SpectrumData
|
||||
) => {
|
||||
const timeSlices = spectrum.time_slices;
|
||||
if (timeSlices.length === 0) return;
|
||||
|
||||
const freqBins = timeSlices[0].magnitudes.length;
|
||||
const nyquistFreq = spectrum.max_freq;
|
||||
|
||||
// Find min/max dB values
|
||||
let minDB = 0;
|
||||
let maxDB = -200;
|
||||
|
||||
timeSlices.forEach((slice) => {
|
||||
slice.magnitudes.forEach((db) => {
|
||||
if (db > maxDB) maxDB = db;
|
||||
if (db < minDB && db > -200) minDB = db;
|
||||
});
|
||||
});
|
||||
|
||||
// Clamp range for better visualization
|
||||
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
|
||||
const dbRange = maxDB - minDB;
|
||||
|
||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
||||
|
||||
for (let t = 0; t < timeSlices.length; t++) {
|
||||
const slice = timeSlices[t];
|
||||
const xPos = x + (t / timeSlices.length) * width;
|
||||
|
||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||
const db = slice.magnitudes[f];
|
||||
|
||||
// Linear frequency scale
|
||||
const freq = (f / freqBins) * nyquistFreq;
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
|
||||
// Calculate bin height
|
||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||
const nextYPos = y + height - (nextFreqRatio * height);
|
||||
|
||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||
|
||||
// Normalize intensity
|
||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Vibrant color scheme like Spek - NGEJERENG!
|
||||
const getSpekColor = (intensity: number): string => {
|
||||
if (intensity < 0.08) {
|
||||
// Black to deep blue
|
||||
const t = intensity / 0.08;
|
||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||
} else if (intensity < 0.18) {
|
||||
// Deep blue to bright blue
|
||||
const t = (intensity - 0.08) / 0.10;
|
||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
||||
} else if (intensity < 0.28) {
|
||||
// Blue to magenta/purple
|
||||
const t = (intensity - 0.18) / 0.10;
|
||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
||||
} else if (intensity < 0.40) {
|
||||
// Magenta to bright red
|
||||
const t = (intensity - 0.28) / 0.12;
|
||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
||||
} else if (intensity < 0.52) {
|
||||
// Red to orange-red
|
||||
const t = (intensity - 0.40) / 0.12;
|
||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||
} else if (intensity < 0.65) {
|
||||
// Orange-red to bright orange
|
||||
const t = (intensity - 0.52) / 0.13;
|
||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||
} else if (intensity < 0.78) {
|
||||
// Orange to yellow-orange
|
||||
const t = (intensity - 0.65) / 0.13;
|
||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||
} else if (intensity < 0.90) {
|
||||
// Yellow-orange to bright yellow
|
||||
const t = (intensity - 0.78) / 0.12;
|
||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||
} else {
|
||||
// Yellow to white (hottest)
|
||||
const t = (intensity - 0.90) / 0.10;
|
||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||
}
|
||||
};
|
||||
|
||||
const drawAxesAndLabels = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
nyquistFreq: number,
|
||||
duration: number,
|
||||
sampleRate: number
|
||||
) => {
|
||||
// Frequency labels on Y-axis
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// Generate frequency labels based on Nyquist
|
||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||
|
||||
freqLabels.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||
ctx.fillText(label, x - 8, yPos);
|
||||
}
|
||||
});
|
||||
|
||||
// "0" at bottom
|
||||
ctx.fillText("0", x - 8, y + height);
|
||||
|
||||
// Time labels on X-axis
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
const timeStep = getTimeStep(duration);
|
||||
for (let t = 0; t <= duration; t += timeStep) {
|
||||
const xPos = x + (t / duration) * width;
|
||||
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
||||
}
|
||||
|
||||
// Axis titles
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "13px Arial";
|
||||
|
||||
// Y-axis title: "Frequency (Hz)"
|
||||
ctx.save();
|
||||
ctx.translate(12, y + height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// X-axis title: "Time (seconds)"
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
||||
|
||||
// Sample rate info in top right
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
||||
};
|
||||
|
||||
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
||||
if (nyquistFreq <= 24000) {
|
||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
||||
} else if (nyquistFreq <= 48000) {
|
||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
||||
} else if (nyquistFreq <= 96000) {
|
||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
||||
} else {
|
||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeStep = (duration: number): number => {
|
||||
// Always use 30s intervals like the reference image
|
||||
if (duration <= 60) return 15;
|
||||
if (duration <= 120) return 30;
|
||||
if (duration <= 300) return 30;
|
||||
if (duration <= 600) return 60;
|
||||
return 60;
|
||||
};
|
||||
|
||||
const drawColorBar = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
// Draw gradient color bar
|
||||
for (let i = 0; i < height; i++) {
|
||||
const intensity = 1 - (i / height); // Top is high, bottom is low
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y + i, width, 1);
|
||||
}
|
||||
|
||||
// Border around color bar
|
||||
ctx.strokeStyle = "#666666";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
|
||||
// Labels
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "11px Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
ctx.fillText("High", x + width + 5, y + 10);
|
||||
ctx.fillText("Low", x + width + 5, y + height - 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={1200}
|
||||
height={600}
|
||||
className="w-full h-auto"
|
||||
style={{ imageRendering: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas)
|
||||
return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx)
|
||||
return;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const marginLeft = 70;
|
||||
const marginRight = 70;
|
||||
const marginTop = 30;
|
||||
const marginBottom = 65;
|
||||
const plotWidth = width - marginLeft - marginRight;
|
||||
const plotHeight = height - marginTop - marginBottom;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
const nyquistFreq = sampleRate / 2;
|
||||
if (spectrumData) {
|
||||
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
|
||||
}
|
||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
|
||||
const timeSlices = spectrum.time_slices;
|
||||
if (timeSlices.length === 0)
|
||||
return;
|
||||
const freqBins = timeSlices[0].magnitudes.length;
|
||||
const nyquistFreq = spectrum.max_freq;
|
||||
let minDB = 0;
|
||||
let maxDB = -200;
|
||||
timeSlices.forEach((slice) => {
|
||||
slice.magnitudes.forEach((db) => {
|
||||
if (db > maxDB)
|
||||
maxDB = db;
|
||||
if (db < minDB && db > -200)
|
||||
minDB = db;
|
||||
});
|
||||
});
|
||||
minDB = Math.max(minDB, maxDB - 90);
|
||||
const dbRange = maxDB - minDB;
|
||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
||||
for (let t = 0; t < timeSlices.length; t++) {
|
||||
const slice = timeSlices[t];
|
||||
const xPos = x + (t / timeSlices.length) * width;
|
||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||
const db = slice.magnitudes[f];
|
||||
const freq = (f / freqBins) * nyquistFreq;
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||
const nextYPos = y + height - (nextFreqRatio * height);
|
||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getSpekColor = (intensity: number): string => {
|
||||
if (intensity < 0.08) {
|
||||
const t = intensity / 0.08;
|
||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||
}
|
||||
else if (intensity < 0.18) {
|
||||
const t = (intensity - 0.08) / 0.10;
|
||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
||||
}
|
||||
else if (intensity < 0.28) {
|
||||
const t = (intensity - 0.18) / 0.10;
|
||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
||||
}
|
||||
else if (intensity < 0.40) {
|
||||
const t = (intensity - 0.28) / 0.12;
|
||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
||||
}
|
||||
else if (intensity < 0.52) {
|
||||
const t = (intensity - 0.40) / 0.12;
|
||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||
}
|
||||
else if (intensity < 0.65) {
|
||||
const t = (intensity - 0.52) / 0.13;
|
||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||
}
|
||||
else if (intensity < 0.78) {
|
||||
const t = (intensity - 0.65) / 0.13;
|
||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||
}
|
||||
else if (intensity < 0.90) {
|
||||
const t = (intensity - 0.78) / 0.12;
|
||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||
}
|
||||
else {
|
||||
const t = (intensity - 0.90) / 0.10;
|
||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||
}
|
||||
};
|
||||
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||
freqLabels.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||
ctx.fillText(label, x - 8, yPos);
|
||||
}
|
||||
});
|
||||
ctx.fillText("0", x - 8, y + height);
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const timeStep = getTimeStep(duration);
|
||||
for (let t = 0; t <= duration; t += timeStep) {
|
||||
const xPos = x + (t / duration) * width;
|
||||
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
||||
}
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "13px Arial";
|
||||
ctx.save();
|
||||
ctx.translate(12, y + height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
||||
};
|
||||
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
||||
if (nyquistFreq <= 24000) {
|
||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
||||
}
|
||||
else if (nyquistFreq <= 48000) {
|
||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
||||
}
|
||||
else if (nyquistFreq <= 96000) {
|
||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
||||
}
|
||||
else {
|
||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
||||
}
|
||||
};
|
||||
const getTimeStep = (duration: number): number => {
|
||||
if (duration <= 60)
|
||||
return 15;
|
||||
if (duration <= 120)
|
||||
return 30;
|
||||
if (duration <= 300)
|
||||
return 30;
|
||||
if (duration <= 600)
|
||||
return 60;
|
||||
return 60;
|
||||
};
|
||||
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
|
||||
for (let i = 0; i < height; i++) {
|
||||
const intensity = 1 - (i / height);
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y + i, width, 1);
|
||||
}
|
||||
ctx.strokeStyle = "#666666";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "11px Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("High", x + width + 5, y + 10);
|
||||
ctx.fillText("Low", x + width + 5, y + height - 10);
|
||||
};
|
||||
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,30 @@
|
||||
import { X, Minus, Maximize } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
export function TitleBar() {
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
WindowToggleMaximise();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
Quit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Draggable area */}
|
||||
<div
|
||||
className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm"
|
||||
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
|
||||
onDoubleClick={handleMaximize}
|
||||
/>
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
const handleMaximize = () => {
|
||||
WindowToggleMaximise();
|
||||
};
|
||||
const handleClose = () => {
|
||||
Quit();
|
||||
};
|
||||
return (<>
|
||||
|
||||
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
|
||||
|
||||
|
||||
{/* Window control buttons - Windows style, right side */}
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
|
||||
<Minus className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Maximize"
|
||||
>
|
||||
<Maximize className="w-3.5 h-3.5" />
|
||||
<button onClick={handleMaximize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Maximize">
|
||||
<Maximize className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<button onClick={handleClose} className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Close">
|
||||
<X className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</>);
|
||||
}
|
||||
|
||||
@@ -2,206 +2,138 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & { album_name: string; release_date: string };
|
||||
isDownloading: boolean;
|
||||
downloadingTrack: string | null;
|
||||
isDownloaded: boolean;
|
||||
isFailed: boolean;
|
||||
isSkipped: boolean;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
downloadedLyrics?: boolean;
|
||||
failedLyrics?: boolean;
|
||||
skippedLyrics?: boolean;
|
||||
checkingAvailability?: boolean;
|
||||
availability?: TrackAvailability;
|
||||
downloadingCover?: boolean;
|
||||
downloadedCover?: boolean;
|
||||
failedCover?: boolean;
|
||||
skippedCover?: boolean;
|
||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onOpenFolder: () => void;
|
||||
track: TrackMetadata & {
|
||||
album_name: string;
|
||||
release_date: string;
|
||||
};
|
||||
isDownloading: boolean;
|
||||
downloadingTrack: string | null;
|
||||
isDownloaded: boolean;
|
||||
isFailed: boolean;
|
||||
isSkipped: boolean;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
downloadedLyrics?: boolean;
|
||||
failedLyrics?: boolean;
|
||||
skippedLyrics?: boolean;
|
||||
checkingAvailability?: boolean;
|
||||
availability?: TrackAvailability;
|
||||
downloadingCover?: boolean;
|
||||
downloadedCover?: boolean;
|
||||
failedCover?: boolean;
|
||||
skippedCover?: boolean;
|
||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
export function TrackInfo({
|
||||
track,
|
||||
isDownloading,
|
||||
downloadingTrack,
|
||||
isDownloaded,
|
||||
isFailed,
|
||||
isSkipped,
|
||||
downloadingLyricsTrack,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
checkingAvailability,
|
||||
availability,
|
||||
downloadingCover,
|
||||
downloadedCover,
|
||||
failedCover,
|
||||
skippedCover,
|
||||
onDownload,
|
||||
onDownloadLyrics,
|
||||
onCheckAvailability,
|
||||
onDownloadCover,
|
||||
onOpenFolder,
|
||||
}: TrackInfoProps) {
|
||||
return (
|
||||
<Card>
|
||||
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const formatPlays = (plays: string) => {
|
||||
const num = parseInt(plays, 10);
|
||||
if (isNaN(num))
|
||||
return plays;
|
||||
return num.toLocaleString();
|
||||
};
|
||||
return (<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
{track.images && (
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
||||
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||
{isSkipped ? (
|
||||
<FileCheck className="h-6 w-6 text-yellow-500 shrink-0" />
|
||||
) : isDownloaded ? (
|
||||
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
|
||||
) : isFailed ? (
|
||||
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
|
||||
) : null}
|
||||
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Album</p>
|
||||
<p className="font-medium truncate">{track.album_name}</p>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Album</p>
|
||||
<p className="font-medium truncate">{track.album_name}</p>
|
||||
</div>
|
||||
{track.plays && (<div>
|
||||
<p className="text-xs text-muted-foreground">Total Plays</p>
|
||||
<p className="font-medium">{formatPlays(track.plays)}</p>
|
||||
</div>)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||
<p className="font-medium">{track.release_date}</p>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||
<p className="font-medium">{track.release_date}</p>
|
||||
</div>
|
||||
{track.copyright && (<div>
|
||||
<p className="text-xs text-muted-foreground">Copyright</p>
|
||||
<p className="font-medium truncate" title={track.copyright}>
|
||||
{track.copyright}
|
||||
</p>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
{track.isrc && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)}
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
||||
<Download className="h-4 w-4"/>
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</>)}
|
||||
</Button>
|
||||
{track.spotify_id && onDownloadLyrics && (
|
||||
<Tooltip>
|
||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)}
|
||||
variant="outline"
|
||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
||||
>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : skippedLyrics ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedLyrics ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedLyrics ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.images && onDownloadCover && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)}
|
||||
variant="outline"
|
||||
disabled={downloadingCover}
|
||||
>
|
||||
{downloadingCover ? (
|
||||
<Spinner />
|
||||
) : skippedCover ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedCover ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedCover ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<ImageDown className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingCover}>
|
||||
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onCheckAvailability && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
|
||||
variant="outline"
|
||||
disabled={checkingAvailability}
|
||||
>
|
||||
{checkingAvailability ? (
|
||||
<Spinner />
|
||||
) : availability ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
|
||||
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availability ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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"}`} />
|
||||
</div>
|
||||
) : (
|
||||
<p>Check Availability</p>
|
||||
)}
|
||||
{availability ? (<div className="flex items-center gap-2">
|
||||
<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"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDownloaded && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Tooltip>)}
|
||||
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Button>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
}
|
||||
|
||||
@@ -2,486 +2,325 @@ import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
showCheckboxes?: boolean;
|
||||
hideAlbumColumn?: boolean;
|
||||
folderName?: string;
|
||||
isArtistDiscography?: boolean;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
tracks: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
showCheckboxes?: boolean;
|
||||
hideAlbumColumn?: boolean;
|
||||
folderName?: string;
|
||||
isArtistDiscography?: boolean;
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAlbumClick?: (album: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onArtistClick?: (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
}
|
||||
|
||||
export function TrackList({
|
||||
tracks,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
failedTracks,
|
||||
skippedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
showCheckboxes = false,
|
||||
hideAlbumColumn = false,
|
||||
folderName,
|
||||
isArtistDiscography = false,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
downloadingLyricsTrack,
|
||||
checkingAvailabilityTrack,
|
||||
availabilityMap,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onCheckAvailability,
|
||||
onDownloadCover,
|
||||
onPageChange,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
onTrackClick,
|
||||
}: TrackListProps) {
|
||||
let filteredTracks = tracks.filter((track) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
track.name.toLowerCase().includes(query) ||
|
||||
track.artists.toLowerCase().includes(query) ||
|
||||
track.album_name.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Apply sorting
|
||||
if (sortBy === "title-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (sortBy === "title-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
|
||||
} else if (sortBy === "artist-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
|
||||
} else if (sortBy === "artist-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
|
||||
} else if (sortBy === "duration-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
|
||||
} else if (sortBy === "duration-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
|
||||
} else if (sortBy === "downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
||||
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
|
||||
let filteredTracks = tracks.filter((track) => {
|
||||
if (!searchQuery)
|
||||
return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (track.name.toLowerCase().includes(query) ||
|
||||
track.artists.toLowerCase().includes(query) ||
|
||||
track.album_name.toLowerCase().includes(query));
|
||||
});
|
||||
} else if (sortBy === "not-downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
||||
|
||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
||||
const allSelected =
|
||||
tracksWithIsrc.length > 0 &&
|
||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
if (sortBy === "title-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
else if (sortBy === "title-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
|
||||
}
|
||||
else if (sortBy === "artist-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
|
||||
}
|
||||
else if (sortBy === "artist-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
|
||||
}
|
||||
else if (sortBy === "duration-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
|
||||
}
|
||||
else if (sortBy === "duration-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
|
||||
}
|
||||
else if (sortBy === "plays-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
|
||||
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
|
||||
if (isNaN(aPlays))
|
||||
return 1;
|
||||
if (isNaN(bPlays))
|
||||
return -1;
|
||||
return aPlays - bPlays;
|
||||
});
|
||||
}
|
||||
else if (sortBy === "plays-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
|
||||
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
|
||||
if (isNaN(aPlays))
|
||||
return 1;
|
||||
if (isNaN(bPlays))
|
||||
return -1;
|
||||
return bPlays - aPlays;
|
||||
});
|
||||
}
|
||||
else if (sortBy === "downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
else if (sortBy === "not-downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
||||
const allSelected = tracksWithIsrc.length > 0 &&
|
||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const formatPlays = (plays: string | undefined) => {
|
||||
if (!plays)
|
||||
return "";
|
||||
const num = parseInt(plays, 10);
|
||||
if (isNaN(num))
|
||||
return plays;
|
||||
return num.toLocaleString();
|
||||
};
|
||||
return (<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{showCheckboxes && (
|
||||
<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||
Title
|
||||
</th>
|
||||
{!hideAlbumColumn && (
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
Album
|
||||
</th>
|
||||
)}
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||
Duration
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
||||
Plays
|
||||
</th>
|
||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTracks.map((track, index) => (
|
||||
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (
|
||||
<td className="p-4 align-middle">
|
||||
{track.isrc && (
|
||||
<Checkbox
|
||||
checked={selectedTracks.includes(track.isrc)}
|
||||
onCheckedChange={() => onToggleTrack(track.isrc)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (<td className="p-4 align-middle">
|
||||
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||
{startIndex + index + 1}
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>{startIndex + index + 1}</span>
|
||||
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
||||
? "text-green-500"
|
||||
: track.status === "DOWN"
|
||||
? "text-red-500"
|
||||
: track.status === "NEW"
|
||||
? "text-blue-500"
|
||||
: ""}`}>
|
||||
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
||||
</span>)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
{track.images && (
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-10 h-10 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{onTrackClick ? (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline"
|
||||
onClick={() => onTrackClick(track)}
|
||||
>
|
||||
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
||||
{track.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{track.name}</span>
|
||||
)}
|
||||
{skippedTracks.has(track.isrc) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : null}
|
||||
</span>) : (<span className="font-medium">{track.name}</span>)}
|
||||
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists_data && track.artists_data.length > 0 ? (
|
||||
track.artists_data.map((artist, i, arr) => (
|
||||
<span key={artist.id}>
|
||||
{onArtistClick ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
external_urls: artist.external_urls,
|
||||
})
|
||||
}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
) : (
|
||||
artist.name
|
||||
)}
|
||||
{i < arr.length - 1 && ", "}
|
||||
</span>
|
||||
))
|
||||
) : onArtistClick && track.artist_id && track.artist_url ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: track.artist_id!,
|
||||
name: track.artists,
|
||||
external_urls: track.artist_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||
const artistNames = track.artists.split(", ").map(name => name.trim());
|
||||
return artistNames.map((name, i) => {
|
||||
const artistData = track.artists_data![i];
|
||||
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
||||
return (<span key={artistData?.id || i}>
|
||||
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
id: artistData.id,
|
||||
name: name,
|
||||
external_urls: artistData.external_urls,
|
||||
})}>
|
||||
{name}
|
||||
</span>) : (name)}
|
||||
{i < artistNames.length - 1 && ", "}
|
||||
</span>);
|
||||
});
|
||||
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
id: track.artist_id!,
|
||||
name: track.artists,
|
||||
external_urls: track.artist_url!,
|
||||
})}>
|
||||
{track.artists}
|
||||
</span>
|
||||
) : (
|
||||
track.artists
|
||||
)}
|
||||
</span>) : (track.artists)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!hideAlbumColumn && (
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{onAlbumClick && track.album_id && track.album_url ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onAlbumClick({
|
||||
id: track.album_id!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
||||
id: track.album_id!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})}>
|
||||
{track.album_name}
|
||||
</span>
|
||||
) : (
|
||||
track.album_name
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</span>) : (track.album_name)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
||||
{track.plays ? formatPlays(track.plays) : ""}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.isrc && (
|
||||
<Tooltip>
|
||||
{track.isrc && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)
|
||||
}
|
||||
size="sm"
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : skippedTracks.has(track.isrc) ? (
|
||||
<FileCheck className="h-4 w-4" />
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="sm" disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<p>Downloading...</p>
|
||||
) : skippedTracks.has(track.isrc) ? (
|
||||
<p>Already exists</p>
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<p>Downloaded</p>
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<p>Failed</p>
|
||||
) : (
|
||||
<p>Download Track</p>
|
||||
)}
|
||||
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onDownloadLyrics && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
||||
>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : skippedLyrics?.has(track.spotify_id) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedLyrics?.has(track.spotify_id) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedLyrics?.has(track.spotify_id) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="sm" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.images && onDownloadCover && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
|
||||
>
|
||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<Spinner />
|
||||
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<ImageDown className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={() => {
|
||||
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
||||
}} size="sm" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
|
||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onCheckAvailability && (
|
||||
<Tooltip>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={checkingAvailabilityTrack === track.spotify_id}
|
||||
>
|
||||
{checkingAvailabilityTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : availabilityMap?.has(track.spotify_id) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
||||
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availabilityMap?.has(track.spotify_id) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
|
||||
</div>
|
||||
) : (
|
||||
<p>Check Availability</p>
|
||||
)}
|
||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
{totalPages > 1 && (<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
<PaginationPrevious href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1)
|
||||
onPageChange(currentPage - 1);
|
||||
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
|
||||
<PaginationLink href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
}} isActive={currentPage === page} className="cursor-pointer">
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
</PaginationItem>))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) onPageChange(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages)
|
||||
onPageChange(currentPage + 1);
|
||||
}} className={currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
</Pagination>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,104 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ActivityIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
pathOffset: 0,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
pathOffset: [1, 0],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'easeInOut',
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
pathOffset: 0,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
pathOffset: [1, 0],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<motion.path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
ActivityIcon.displayName = 'ActivityIcon';
|
||||
|
||||
export { ActivityIcon };
|
||||
|
||||
@@ -1,46 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
});
|
||||
function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
return (<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props}/>);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,92 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BlocksIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const VARIANTS: Variants = {
|
||||
normal: { translateX: 0, translateY: 0 },
|
||||
animate: { translateX: -4, translateY: 4 },
|
||||
normal: { translateX: 0, translateY: 0 },
|
||||
animate: { translateX: -4, translateY: 4 },
|
||||
};
|
||||
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" />
|
||||
<motion.path
|
||||
d="M14 3h7v7h-7z"
|
||||
variants={VARIANTS}
|
||||
animate={controls}
|
||||
/>
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||
<motion.path d="M14 3h7v7h-7z" variants={VARIANTS} animate={controls}/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
BlocksIcon.displayName = 'BlocksIcon';
|
||||
|
||||
export { BlocksIcon };
|
||||
|
||||
@@ -1,60 +1,35 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
});
|
||||
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props}/>);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,92 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="card" className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="card-header" className={cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="card-content" className={cn("px-6", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props}/>);
|
||||
}
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (<CheckboxPrimitive.Root data-slot="checkbox" className={cn("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
|
||||
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
|
||||
<CheckIcon className="size-3.5"/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
</CheckboxPrimitive.Root>);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1,118 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CoffeeIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: (custom: number) => ({
|
||||
y: -3,
|
||||
opacity: [0, 1, 0],
|
||||
transition: {
|
||||
repeat: Infinity,
|
||||
duration: 1.5,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2 * custom,
|
||||
normal: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
}),
|
||||
animate: (custom: number) => ({
|
||||
y: -3,
|
||||
opacity: [0, 1, 0],
|
||||
transition: {
|
||||
repeat: Infinity,
|
||||
duration: 1.5,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2 * custom,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<motion.path
|
||||
d="M10 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0.2}
|
||||
/>
|
||||
<motion.path
|
||||
d="M14 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0.4}
|
||||
/>
|
||||
<motion.path
|
||||
d="M6 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0}
|
||||
/>
|
||||
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" />
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
|
||||
<motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/>
|
||||
<motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/>
|
||||
<motion.path d="M6 2v2" animate={controls} variants={PATH_VARIANTS} custom={0}/>
|
||||
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
CoffeeIcon.displayName = 'CoffeeIcon';
|
||||
|
||||
export { CoffeeIcon };
|
||||
|
||||
@@ -1,252 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props}/>;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props}/>;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (<ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
return (<ContextMenuPrimitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset} className={cn("focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
<ChevronRightIcon className="ml-auto"/>
|
||||
</ContextMenuPrimitive.SubTrigger>);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (<ContextMenuPrimitive.SubContent data-slot="context-menu-sub-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content data-slot="context-menu-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", className)} {...props}/>
|
||||
</ContextMenuPrimitive.Portal>);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<ContextMenuPrimitive.Item data-slot="context-menu-item" data-inset={inset} data-variant={variant} className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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}
|
||||
>
|
||||
function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (<ContextMenuPrimitive.CheckboxItem data-slot="context-menu-checkbox-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon className="size-4"/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
</ContextMenuPrimitive.CheckboxItem>);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (<ContextMenuPrimitive.RadioItem data-slot="context-menu-radio-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<CircleIcon className="size-2 fill-current"/>
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
</ContextMenuPrimitive.RadioItem>);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<ContextMenuPrimitive.Label data-slot="context-menu-label" data-inset={inset} className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (<ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (<span data-slot="context-menu-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props}/>);
|
||||
}
|
||||
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
|
||||
|
||||
@@ -1,143 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props}/>;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}/>;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props}/>;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props}/>;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (<DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
return (<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DialogPrimitive.Content data-slot="dialog-content" className={cn("bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200", className)} {...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
{showCloseButton && (<DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Close>)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
</DialogPortal>);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
|
||||
}
|
||||
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FileMusicIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
<motion.path
|
||||
d="M14 2v5a1 1 0 0 0 1 1h5"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
<motion.path
|
||||
d="M8 20v-7l3 1.474"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
<motion.circle
|
||||
cx="6"
|
||||
cy="20"
|
||||
r="2"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FileMusicIcon.displayName = 'FileMusicIcon';
|
||||
export { FileMusicIcon };
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FilePenIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
<motion.path
|
||||
d="M14 2v5a1 1 0 0 0 1 1h5"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
<motion.path
|
||||
d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FilePenIcon.displayName = 'FilePenIcon';
|
||||
export { FilePenIcon };
|
||||
@@ -1,149 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface GithubIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const BODY_VARIANTS: Variants = {
|
||||
normal: {
|
||||
opacity: 1,
|
||||
pathLength: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
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,
|
||||
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,
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
draw: {
|
||||
pathLength: [0, 1],
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
draw: {
|
||||
pathLength: [0, 1],
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
wag: {
|
||||
pathLength: 1,
|
||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
wag: {
|
||||
pathLength: 1,
|
||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const 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');
|
||||
},
|
||||
};
|
||||
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>) => {
|
||||
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
}
|
||||
},
|
||||
[bodyControls, onMouseEnter, tailControls]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [bodyControls, onMouseEnter, tailControls]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
}
|
||||
},
|
||||
[bodyControls, tailControls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
variants={BODY_VARIANTS}
|
||||
initial="normal"
|
||||
animate={bodyControls}
|
||||
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
|
||||
/>
|
||||
<motion.path
|
||||
variants={TAIL_VARIANTS}
|
||||
initial="normal"
|
||||
animate={tailControls}
|
||||
d="M9 18c-4.51 2-5-2-7-2"
|
||||
/>
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [bodyControls, tailControls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<motion.path variants={BODY_VARIANTS} initial="normal" animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
|
||||
<motion.path variants={TAIL_VARIANTS} initial="normal" animate={tailControls} d="M9 18c-4.51 2-5-2-7-2"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
GithubIcon.displayName = 'GithubIcon';
|
||||
|
||||
export { GithubIcon };
|
||||
|
||||
@@ -1,103 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import type { Transition, Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface HomeIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
duration: 0.6,
|
||||
opacity: { duration: 0.2 },
|
||||
duration: 0.6,
|
||||
opacity: { duration: 0.2 },
|
||||
};
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
pathLength: [0, 1],
|
||||
},
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
pathLength: [0, 1],
|
||||
},
|
||||
};
|
||||
|
||||
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<motion.path
|
||||
d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"
|
||||
variants={PATH_VARIANTS}
|
||||
transition={DEFAULT_TRANSITION}
|
||||
animate={controls}
|
||||
/>
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<motion.path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" variants={PATH_VARIANTS} transition={DEFAULT_TRANSITION} animate={controls}/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
HomeIcon.displayName = 'HomeIcon';
|
||||
|
||||
export { HomeIcon };
|
||||
|
||||
@@ -1,216 +1,156 @@
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
|
||||
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
|
||||
|
||||
export interface InputWithContextProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onValueChange?: (value: string) => void;
|
||||
export interface InputWithContextProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(
|
||||
({ className, type, onValueChange, onChange, ...props }, ref) => {
|
||||
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(({ className, type, onValueChange, onChange, ...props }, ref) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [hasSelection, setHasSelection] = React.useState(false);
|
||||
const [canPaste, setCanPaste] = React.useState(false);
|
||||
|
||||
// Combine refs
|
||||
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
||||
|
||||
// Check selection state
|
||||
const updateSelectionState = () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
setHasSelection(start !== end);
|
||||
};
|
||||
|
||||
// Check clipboard permission when user explicitly opens the context menu.
|
||||
const checkClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setCanPaste(text.length > 0);
|
||||
} catch {
|
||||
setCanPaste(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCut = async () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
const selectedText = input.value.substring(start, end);
|
||||
|
||||
if (selectedText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
const newValue = input.value.substring(0, start) + input.value.substring(end);
|
||||
|
||||
// Update value and trigger change
|
||||
input.value = newValue;
|
||||
input.setSelectionRange(start, start);
|
||||
|
||||
// Trigger React onChange
|
||||
if (onChange) {
|
||||
const event = {
|
||||
target: input,
|
||||
currentTarget: input,
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(event);
|
||||
}
|
||||
|
||||
if (onValueChange) {
|
||||
onValueChange(newValue);
|
||||
}
|
||||
|
||||
input.focus();
|
||||
} catch (err) {
|
||||
console.error("Failed to cut:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
const selectedText = input.value.substring(start, end);
|
||||
|
||||
if (selectedText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
input.focus();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const input = inputRef.current;
|
||||
if (!input)
|
||||
return;
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
|
||||
const newValue =
|
||||
input.value.substring(0, start) + text + input.value.substring(end);
|
||||
|
||||
// Update value and trigger change
|
||||
input.value = newValue;
|
||||
const newPosition = start + text.length;
|
||||
input.setSelectionRange(newPosition, newPosition);
|
||||
|
||||
// Trigger React onChange
|
||||
if (onChange) {
|
||||
const event = {
|
||||
target: input,
|
||||
currentTarget: input,
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(event);
|
||||
}
|
||||
|
||||
if (onValueChange) {
|
||||
onValueChange(newValue);
|
||||
}
|
||||
|
||||
input.focus();
|
||||
await checkClipboard();
|
||||
} catch (err) {
|
||||
console.error("Failed to paste:", err);
|
||||
}
|
||||
setHasSelection(start !== end);
|
||||
};
|
||||
const checkClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setCanPaste(text.length > 0);
|
||||
}
|
||||
catch {
|
||||
setCanPaste(false);
|
||||
}
|
||||
};
|
||||
const handleCut = async () => {
|
||||
const input = inputRef.current;
|
||||
if (!input)
|
||||
return;
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
const selectedText = input.value.substring(start, end);
|
||||
if (selectedText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
const newValue = input.value.substring(0, start) + input.value.substring(end);
|
||||
input.value = newValue;
|
||||
input.setSelectionRange(start, start);
|
||||
if (onChange) {
|
||||
const event = {
|
||||
target: input,
|
||||
currentTarget: input,
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(event);
|
||||
}
|
||||
if (onValueChange) {
|
||||
onValueChange(newValue);
|
||||
}
|
||||
input.focus();
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to cut:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleCopy = async () => {
|
||||
const input = inputRef.current;
|
||||
if (!input)
|
||||
return;
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
const selectedText = input.value.substring(start, end);
|
||||
if (selectedText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedText);
|
||||
input.focus();
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
const handlePaste = async () => {
|
||||
const input = inputRef.current;
|
||||
if (!input)
|
||||
return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
const newValue = input.value.substring(0, start) + text + input.value.substring(end);
|
||||
input.value = newValue;
|
||||
const newPosition = start + text.length;
|
||||
input.setSelectionRange(newPosition, newPosition);
|
||||
if (onChange) {
|
||||
const event = {
|
||||
target: input,
|
||||
currentTarget: input,
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(event);
|
||||
}
|
||||
if (onValueChange) {
|
||||
onValueChange(newValue);
|
||||
}
|
||||
input.focus();
|
||||
await checkClipboard();
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to paste:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.select();
|
||||
input.focus();
|
||||
updateSelectionState();
|
||||
const input = inputRef.current;
|
||||
if (!input)
|
||||
return;
|
||||
input.select();
|
||||
input.focus();
|
||||
updateSelectionState();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
if (onValueChange) {
|
||||
onValueChange(e.target.value);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
if (onValueChange) {
|
||||
onValueChange(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
checkClipboard();
|
||||
}
|
||||
}}
|
||||
>
|
||||
return (<ContextMenu onOpenChange={(open) => {
|
||||
if (open) {
|
||||
checkClipboard();
|
||||
}
|
||||
}}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={type}
|
||||
className={className}
|
||||
onChange={handleInputChange}
|
||||
onSelect={updateSelectionState}
|
||||
onMouseUp={updateSelectionState}
|
||||
onKeyUp={updateSelectionState}
|
||||
{...props}
|
||||
/>
|
||||
<Input ref={inputRef} type={type} className={className} onChange={handleInputChange} onSelect={updateSelectionState} onMouseUp={updateSelectionState} onKeyUp={updateSelectionState} {...props}/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem
|
||||
onSelect={handleCut}
|
||||
disabled={!hasSelection || props.disabled || props.readOnly}
|
||||
>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
<ContextMenuItem onSelect={handleCut} disabled={!hasSelection || props.disabled || props.readOnly}>
|
||||
<Scissors className="mr-2 h-4 w-4"/>
|
||||
Cut
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={handleCopy}
|
||||
disabled={!hasSelection || props.disabled}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<ContextMenuItem onSelect={handleCopy} disabled={!hasSelection || props.disabled}>
|
||||
<Copy className="mr-2 h-4 w-4"/>
|
||||
Copy
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={handlePaste}
|
||||
disabled={!canPaste || props.disabled || props.readOnly}
|
||||
>
|
||||
<Clipboard className="mr-2 h-4 w-4" />
|
||||
<ContextMenuItem onSelect={handlePaste} disabled={!canPaste || props.disabled || props.readOnly}>
|
||||
<Clipboard className="mr-2 h-4 w-4"/>
|
||||
Paste
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={handleSelectAll}
|
||||
disabled={!inputRef.current?.value || props.disabled}
|
||||
>
|
||||
<Type className="mr-2 h-4 w-4" />
|
||||
<ContextMenuItem onSelect={handleSelectAll} disabled={!inputRef.current?.value || props.disabled}>
|
||||
<Type className="mr-2 h-4 w-4"/>
|
||||
Select All
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</ContextMenu>);
|
||||
});
|
||||
InputWithContext.displayName = "InputWithContext";
|
||||
|
||||
export { InputWithContext };
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<input type={type} data-slot="input" className={cn("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className)} {...props}/>);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (<LabelPrimitive.Root data-slot="label" className={cn("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className)} {...props}/>);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,127 +1,41 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<nav role="navigation" aria-label="pagination" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (<ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
return <li data-slot="pagination-item" {...props}/>;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> & React.ComponentProps<"a">;
|
||||
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
|
||||
return (<a aria-current={isActive ? "page" : undefined} data-slot="pagination-link" data-active={isActive} className={cn(buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}), className)} {...props}/>);
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 px-2.5 sm:pl-2.5", className)} {...props}>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
</PaginationLink>);
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 px-2.5 sm:pr-2.5", className)} {...props}>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
</PaginationLink>);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (<span aria-hidden data-slot="pagination-ellipsis" className={cn("flex size-9 items-center justify-center", className)} {...props}>
|
||||
<MoreHorizontalIcon className="size-4"/>
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
</span>);
|
||||
}
|
||||
export { Pagination, PaginationContent, PaginationLink, PaginationItem, PaginationPrevious, PaginationNext, PaginationEllipsis, };
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (<ProgressPrimitive.Root data-slot="progress" className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)} {...props}>
|
||||
<ProgressPrimitive.Indicator data-slot="progress-indicator" className="bg-primary h-full w-full flex-1 transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }}/>
|
||||
</ProgressPrimitive.Root>);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,185 +1,63 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props}/>;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props}/>;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props}/>;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
function SelectTrigger({ className, size = "default", children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
return (<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn("border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
<ChevronDownIcon className="size-4 opacity-50"/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
</SelectPrimitive.Trigger>);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
function SelectContent({ className, children, position = "popper", align = "center", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content data-slot="select-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className)} position={position} align={align} {...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Viewport className={cn("p-1", position === "popper" &&
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
</SelectPrimitive.Portal>);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
</SelectPrimitive.Item>);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (<SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props}/>);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (<SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
|
||||
<ChevronUpIcon className="size-4"/>
|
||||
</SelectPrimitive.ScrollUpButton>);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (<SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
|
||||
<ChevronDownIcon className="size-4"/>
|
||||
</SelectPrimitive.ScrollDownButton>);
|
||||
}
|
||||
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };
|
||||
|
||||
@@ -1,92 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SettingsIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transition={{ type: 'spring', stiffness: 50, damping: 10 }}
|
||||
variants={{
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<motion.svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" transition={{ type: 'spring', stiffness: 50, damping: 10 }} variants={{
|
||||
normal: {
|
||||
rotate: 0,
|
||||
rotate: 0,
|
||||
},
|
||||
animate: {
|
||||
rotate: 180,
|
||||
rotate: 180,
|
||||
},
|
||||
}}
|
||||
animate={controls}
|
||||
>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
}} animate={controls}>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</motion.svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
SettingsIcon.displayName = 'SettingsIcon';
|
||||
|
||||
export { SettingsIcon };
|
||||
|
||||
@@ -1,47 +1,27 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
|
||||
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
|
||||
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
|
||||
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
|
||||
},
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
left: "calc(56px + 1rem)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
const { theme = "system" } = useTheme();
|
||||
return (<Sonner theme={theme as ToasterProps["theme"]} className="toaster group" icons={{
|
||||
success: <CircleCheckIcon className="size-4"/>,
|
||||
info: <InfoIcon className="size-4"/>,
|
||||
warning: <TriangleAlertIcon className="size-4"/>,
|
||||
error: <OctagonXIcon className="size-4"/>,
|
||||
loading: <Loader2Icon className="size-4 animate-spin"/>,
|
||||
}} toastOptions={{
|
||||
classNames: {
|
||||
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
|
||||
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
|
||||
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
|
||||
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
|
||||
},
|
||||
}} style={{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
left: "calc(56px + 1rem)",
|
||||
} as React.CSSProperties} {...props}/>);
|
||||
};
|
||||
export { Toaster };
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (<Loader2 role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props}/>);
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
export { Spinner };
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (<SwitchPrimitive.Root data-slot="switch" className={cn("peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
|
||||
<SwitchPrimitive.Thumb data-slot="switch-thumb" className={cn("pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background")}/>
|
||||
</SwitchPrimitive.Root>);
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -1,103 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TerminalIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const LINE_VARIANTS: Variants = {
|
||||
normal: { opacity: 1 },
|
||||
animate: {
|
||||
opacity: [1, 0, 1],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
normal: { opacity: 1 },
|
||||
animate: {
|
||||
opacity: [1, 0, 1],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start('animate');
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
controls.start('normal');
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<motion.line
|
||||
x1="12"
|
||||
x2="20"
|
||||
y1="19"
|
||||
y2="19"
|
||||
variants={LINE_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={controls} initial="normal"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
</div>);
|
||||
});
|
||||
TerminalIcon.displayName = 'TerminalIcon';
|
||||
|
||||
export { TerminalIcon };
|
||||
|
||||
@@ -1,83 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
});
|
||||
function ToggleGroup({ className, variant, size, spacing = 0, children, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}) {
|
||||
return (<ToggleGroupPrimitive.Root data-slot="toggle-group" data-variant={variant} data-size={size} data-spacing={spacing} style={{ "--gap": spacing } as React.CSSProperties} className={cn("group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs", className)} {...props}>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
</ToggleGroupPrimitive.Root>);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
return (<ToggleGroupPrimitive.Item data-slot="toggle-group-item" data-variant={context.variant || variant} data-size={context.size || size} data-spacing={context.spacing} className={cn(toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}), "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10", "data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l", className)} {...props}>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
</ToggleGroupPrimitive.Item>);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
|
||||
@@ -1,47 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
const toggleVariants = cva("inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
});
|
||||
function Toggle({ className, variant, size, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (<TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props}/>);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
export { Toggle, toggleVariants };
|
||||
|
||||
@@ -1,61 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props}/>);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props}/>
|
||||
</TooltipProvider>);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props}/>;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
function TooltipContent({ className, sideOffset = 0, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content data-slot="tooltip-content" sideOffset={sideOffset} className={cn("bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", className)} {...props}>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]"/>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
</TooltipPrimitive.Portal>);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -4,169 +4,144 @@ import type { AnalysisResult } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
|
||||
|
||||
const STORAGE_KEY = "spotiflac_audio_analysis_state";
|
||||
|
||||
export function useAudioAnalysis() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
||||
// Load from sessionStorage on mount - only detail, no spectrum
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
// Return result WITHOUT spectrum - spectrum will be loaded async
|
||||
return {
|
||||
...parsed.result,
|
||||
spectrum: undefined,
|
||||
};
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
return {
|
||||
...parsed.result,
|
||||
spectrum: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load saved analysis state:", err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
||||
// Load file path from sessionStorage
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.filePath || "";
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
||||
// If result exists from sessionStorage, show loading for spectrum
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
// Always show loading initially, will be resolved async
|
||||
return true;
|
||||
catch (err) {
|
||||
console.error("Failed to load saved analysis state:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const analyzeFile = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
setError("No file path provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
setAnalyzing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setSelectedFilePath(filePath);
|
||||
|
||||
try {
|
||||
logger.info(`Analyzing audio file: ${filePath}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await AnalyzeTrack(filePath);
|
||||
const analysisResult: AnalysisResult = JSON.parse(response);
|
||||
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
|
||||
// Save spectrum to memory cache
|
||||
if (analysisResult.spectrum) {
|
||||
setSpectrumCache(filePath, analysisResult.spectrum);
|
||||
}
|
||||
|
||||
// Save detail (without spectrum) to sessionStorage
|
||||
const { spectrum, ...detailResult } = analysisResult;
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
filePath,
|
||||
result: detailResult,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Failed to save analysis state:", err);
|
||||
}
|
||||
|
||||
setResult(analysisResult);
|
||||
setSpectrumLoading(false); // Spectrum is now available
|
||||
|
||||
return analysisResult;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
setError(errorMessage);
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearResult = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setSelectedFilePath("");
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
clearSpectrumCache();
|
||||
}, []);
|
||||
|
||||
// Load spectrum from cache asynchronously after detail is displayed
|
||||
useEffect(() => {
|
||||
// Only load spectrum if we have result without spectrum and are in loading state
|
||||
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load spectrum asynchronously to avoid blocking UI
|
||||
// Use requestAnimationFrame to ensure detail renders first
|
||||
let rafId: number;
|
||||
const loadSpectrum = () => {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
||||
if (cachedSpectrum) {
|
||||
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
||||
setSpectrumLoading(false);
|
||||
} else {
|
||||
// Spectrum not in cache - user needs to re-analyze
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Double RAF to ensure detail is fully rendered
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(loadSpectrum);
|
||||
return null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.filePath || "";
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const analyzeFile = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
setError("No file path provided");
|
||||
return null;
|
||||
}
|
||||
setAnalyzing(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setSelectedFilePath(filePath);
|
||||
try {
|
||||
logger.info(`Analyzing audio file: ${filePath}`);
|
||||
const startTime = Date.now();
|
||||
const response = await AnalyzeTrack(filePath);
|
||||
const analysisResult: AnalysisResult = JSON.parse(response);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
if (analysisResult.spectrum) {
|
||||
setSpectrumCache(filePath, analysisResult.spectrum);
|
||||
}
|
||||
const { spectrum, ...detailResult } = analysisResult;
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
filePath,
|
||||
result: detailResult,
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to save analysis state:", err);
|
||||
}
|
||||
setResult(analysisResult);
|
||||
setSpectrumLoading(false);
|
||||
return analysisResult;
|
||||
}
|
||||
catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
setError(errorMessage);
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}, []);
|
||||
const clearResult = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setSelectedFilePath("");
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
clearSpectrumCache();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
let rafId: number;
|
||||
const loadSpectrum = () => {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
||||
if (cachedSpectrum) {
|
||||
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
else {
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(loadSpectrum);
|
||||
});
|
||||
return () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [result, selectedFilePath, spectrumLoading]);
|
||||
return {
|
||||
analyzing,
|
||||
result,
|
||||
error,
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
analyzeFile,
|
||||
clearResult,
|
||||
};
|
||||
}, [result, selectedFilePath, spectrumLoading]);
|
||||
|
||||
return {
|
||||
analyzing,
|
||||
result,
|
||||
error,
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
analyzeFile,
|
||||
clearResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,68 +2,59 @@ import { useState, useCallback } from "react";
|
||||
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
|
||||
import type { TrackAvailability } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export function useAvailability() {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
|
||||
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
|
||||
if (!spotifyId) {
|
||||
setError("No Spotify ID provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if already cached
|
||||
if (availabilityMap.has(spotifyId)) {
|
||||
return availabilityMap.get(spotifyId)!;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
setCheckingTrackId(spotifyId);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.info(`Checking availability for track: ${spotifyId}`);
|
||||
const response = await CheckTrackAvailability(spotifyId, isrc || "");
|
||||
const availability: TrackAvailability = JSON.parse(response);
|
||||
|
||||
setAvailabilityMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(spotifyId, availability);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
logger.success(`Availability check completed for ${spotifyId}`);
|
||||
return availability;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
|
||||
logger.error(`Availability check error: ${errorMessage}`);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
setCheckingTrackId(null);
|
||||
}
|
||||
}, [availabilityMap]);
|
||||
|
||||
const getAvailability = useCallback((spotifyId: string) => {
|
||||
return availabilityMap.get(spotifyId);
|
||||
}, [availabilityMap]);
|
||||
|
||||
const clearAvailability = useCallback(() => {
|
||||
setAvailabilityMap(new Map());
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
checking,
|
||||
checkingTrackId,
|
||||
availabilityMap,
|
||||
error,
|
||||
checkAvailability,
|
||||
getAvailability,
|
||||
clearAvailability,
|
||||
};
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
|
||||
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
|
||||
if (!spotifyId) {
|
||||
setError("No Spotify ID provided");
|
||||
return null;
|
||||
}
|
||||
if (availabilityMap.has(spotifyId)) {
|
||||
return availabilityMap.get(spotifyId)!;
|
||||
}
|
||||
setChecking(true);
|
||||
setCheckingTrackId(spotifyId);
|
||||
setError(null);
|
||||
try {
|
||||
logger.info(`Checking availability for track: ${spotifyId}`);
|
||||
const response = await CheckTrackAvailability(spotifyId, isrc || "");
|
||||
const availability: TrackAvailability = JSON.parse(response);
|
||||
setAvailabilityMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(spotifyId, availability);
|
||||
return newMap;
|
||||
});
|
||||
logger.success(`Availability check completed for ${spotifyId}`);
|
||||
return availability;
|
||||
}
|
||||
catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
|
||||
logger.error(`Availability check error: ${errorMessage}`);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
setChecking(false);
|
||||
setCheckingTrackId(null);
|
||||
}
|
||||
}, [availabilityMap]);
|
||||
const getAvailability = useCallback((spotifyId: string) => {
|
||||
return availabilityMap.get(spotifyId);
|
||||
}, [availabilityMap]);
|
||||
const clearAvailability = useCallback(() => {
|
||||
setAvailabilityMap(new Map());
|
||||
setError(null);
|
||||
}, []);
|
||||
return {
|
||||
checking,
|
||||
checkingTrackId,
|
||||
availabilityMap,
|
||||
error,
|
||||
checkAvailability,
|
||||
getAvailability,
|
||||
clearAvailability,
|
||||
};
|
||||
}
|
||||
|
||||
+195
-244
@@ -5,253 +5,204 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
export function useCover() {
|
||||
const [downloadingCover, setDownloadingCover] = useState(false);
|
||||
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
|
||||
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
|
||||
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
|
||||
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
|
||||
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
|
||||
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
|
||||
const stopBulkDownloadRef = useRef(false);
|
||||
|
||||
const handleDownloadCover = async (
|
||||
coverUrl: string,
|
||||
trackName: string,
|
||||
artistName: string,
|
||||
albumName?: string,
|
||||
playlistName?: string,
|
||||
position?: number,
|
||||
trackId?: string,
|
||||
albumArtist?: string,
|
||||
releaseDate?: string,
|
||||
discNumber?: number,
|
||||
isAlbum?: boolean
|
||||
) => {
|
||||
if (!coverUrl) {
|
||||
toast.error("No cover URL found for this track");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = trackId || `${trackName}-${artistName}`;
|
||||
logger.info(`downloading cover: ${trackName} - ${artistName}`);
|
||||
const settings = getSettings();
|
||||
setDownloadingCover(true);
|
||||
setDownloadingCoverTrack(id);
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
// Only do this if it's NOT an album download, to avoid double nesting (AlbumName/Artist/AlbumName)
|
||||
if (playlistName && !isAlbum) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
const [downloadingCover, setDownloadingCover] = useState(false);
|
||||
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
|
||||
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
|
||||
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
|
||||
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
|
||||
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
|
||||
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
|
||||
const stopBulkDownloadRef = useRef(false);
|
||||
const handleDownloadCover = async (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
|
||||
if (!coverUrl) {
|
||||
toast.error("No cover URL found for this track");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await downloadCover({
|
||||
cover_url: coverUrl,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName || "",
|
||||
album_artist: albumArtist || "",
|
||||
release_date: releaseDate || "",
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: position || 0,
|
||||
disc_number: discNumber || 0,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info("Cover file already exists");
|
||||
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||
} else {
|
||||
toast.success("Cover downloaded successfully");
|
||||
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
setFailedCovers((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
} finally {
|
||||
setDownloadingCover(false);
|
||||
setDownloadingCoverTrack(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAllCovers = async (
|
||||
tracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
isAlbum?: boolean // Add isAlbum parameter
|
||||
) => {
|
||||
if (tracks.length === 0) {
|
||||
toast.error("No tracks to download covers");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setIsBulkDownloadingCovers(true);
|
||||
setCoverDownloadProgress(0);
|
||||
stopBulkDownloadRef.current = false;
|
||||
|
||||
let completed = 0;
|
||||
let success = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (stopBulkDownloadRef.current) {
|
||||
toast.info("Cover download stopped");
|
||||
break;
|
||||
}
|
||||
|
||||
const track = tracks[i];
|
||||
if (!track.images) {
|
||||
completed++;
|
||||
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
setDownloadingCoverTrack(id);
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
// Determine if we should use album track number or sequential position
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
// Build output path using template system
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
// Only do this if it's NOT an album download
|
||||
if (playlistName && !isAlbum) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
const id = trackId || `${trackName}-${artistName}`;
|
||||
logger.info(`downloading cover: ${trackName} - ${artistName}`);
|
||||
const settings = getSettings();
|
||||
setDownloadingCover(true);
|
||||
setDownloadingCoverTrack(id);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
if (playlistName && !isAlbum) {
|
||||
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) {
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: coverUrl,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName || "",
|
||||
album_artist: albumArtist || "",
|
||||
release_date: releaseDate || "",
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: position || 0,
|
||||
disc_number: discNumber || 0,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info("Cover file already exists");
|
||||
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
else {
|
||||
toast.success("Cover downloaded successfully");
|
||||
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
setFailedCovers((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await downloadCover({
|
||||
cover_url: track.images,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
album_name: track.album_name,
|
||||
album_artist: track.album_artist,
|
||||
release_date: track.release_date,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: trackPosition,
|
||||
disc_number: track.disc_number,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skipped++;
|
||||
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||
} else {
|
||||
success++;
|
||||
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
|
||||
completed++;
|
||||
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||
}
|
||||
|
||||
setDownloadingCoverTrack(null);
|
||||
setIsBulkDownloadingCovers(false);
|
||||
setCoverDownloadProgress(0);
|
||||
|
||||
if (!stopBulkDownloadRef.current) {
|
||||
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopCoverDownload = () => {
|
||||
stopBulkDownloadRef.current = true;
|
||||
};
|
||||
|
||||
const resetCoverState = () => {
|
||||
setDownloadedCovers(new Set());
|
||||
setFailedCovers(new Set());
|
||||
setSkippedCovers(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
downloadingCover,
|
||||
downloadingCoverTrack,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
isBulkDownloadingCovers,
|
||||
coverDownloadProgress,
|
||||
handleDownloadCover,
|
||||
handleDownloadAllCovers,
|
||||
handleStopCoverDownload,
|
||||
resetCoverState,
|
||||
};
|
||||
finally {
|
||||
setDownloadingCover(false);
|
||||
setDownloadingCoverTrack(null);
|
||||
}
|
||||
};
|
||||
const handleDownloadAllCovers = async (tracks: TrackMetadata[], playlistName?: string, isAlbum?: boolean) => {
|
||||
if (tracks.length === 0) {
|
||||
toast.error("No tracks to download covers");
|
||||
return;
|
||||
}
|
||||
const settings = getSettings();
|
||||
setIsBulkDownloadingCovers(true);
|
||||
setCoverDownloadProgress(0);
|
||||
stopBulkDownloadRef.current = false;
|
||||
let completed = 0;
|
||||
let success = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (stopBulkDownloadRef.current) {
|
||||
toast.info("Cover download stopped");
|
||||
break;
|
||||
}
|
||||
const track = tracks[i];
|
||||
if (!track.images) {
|
||||
completed++;
|
||||
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||
continue;
|
||||
}
|
||||
const id = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
setDownloadingCoverTrack(id);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
if (playlistName && !isAlbum) {
|
||||
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) {
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: track.images,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
album_name: track.album_name,
|
||||
album_artist: track.album_artist,
|
||||
release_date: track.release_date,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: trackPosition,
|
||||
disc_number: track.disc_number,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skipped++;
|
||||
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
else {
|
||||
success++;
|
||||
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
}
|
||||
else {
|
||||
failed++;
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
}
|
||||
catch {
|
||||
failed++;
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
completed++;
|
||||
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||
}
|
||||
setDownloadingCoverTrack(null);
|
||||
setIsBulkDownloadingCovers(false);
|
||||
setCoverDownloadProgress(0);
|
||||
if (!stopBulkDownloadRef.current) {
|
||||
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||
}
|
||||
};
|
||||
const handleStopCoverDownload = () => {
|
||||
stopBulkDownloadRef.current = true;
|
||||
};
|
||||
const resetCoverState = () => {
|
||||
setDownloadedCovers(new Set());
|
||||
setFailedCovers(new Set());
|
||||
setSkippedCovers(new Set());
|
||||
};
|
||||
return {
|
||||
downloadingCover,
|
||||
downloadingCoverTrack,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
isBulkDownloadingCovers,
|
||||
coverDownloadProgress,
|
||||
handleDownloadCover,
|
||||
handleDownloadAllCovers,
|
||||
handleStopCoverDownload,
|
||||
resetCoverState,
|
||||
};
|
||||
}
|
||||
|
||||
+844
-968
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,34 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { GetDownloadProgress } from "../../wailsjs/go/main/App";
|
||||
|
||||
export interface DownloadProgressInfo {
|
||||
is_downloading: boolean;
|
||||
mb_downloaded: number;
|
||||
speed_mbps: number;
|
||||
is_downloading: boolean;
|
||||
mb_downloaded: number;
|
||||
speed_mbps: number;
|
||||
}
|
||||
|
||||
export function useDownloadProgress() {
|
||||
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
||||
is_downloading: false,
|
||||
mb_downloaded: 0,
|
||||
speed_mbps: 0,
|
||||
});
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Poll progress every 200ms for smooth updates
|
||||
const pollProgress = async () => {
|
||||
try {
|
||||
const progressInfo = await GetDownloadProgress();
|
||||
setProgress(progressInfo);
|
||||
} catch (error) {
|
||||
console.error("Failed to get download progress:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling
|
||||
intervalRef.current = window.setInterval(pollProgress, 200);
|
||||
|
||||
// Initial fetch
|
||||
pollProgress();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return progress;
|
||||
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
||||
is_downloading: false,
|
||||
mb_downloaded: 0,
|
||||
speed_mbps: 0,
|
||||
});
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const pollProgress = async () => {
|
||||
try {
|
||||
const progressInfo = await GetDownloadProgress();
|
||||
setProgress(progressInfo);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to get download progress:", error);
|
||||
}
|
||||
};
|
||||
intervalRef.current = window.setInterval(pollProgress, 200);
|
||||
pollProgress();
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return progress;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
|
||||
export function useDownloadQueueData() {
|
||||
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
|
||||
new backend.DownloadQueueInfo({
|
||||
is_downloading: false,
|
||||
queue: [],
|
||||
current_speed: 0,
|
||||
total_downloaded: 0,
|
||||
session_start_time: 0,
|
||||
queued_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
skipped_count: 0,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to get download queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchQueue();
|
||||
|
||||
// Poll every 200ms
|
||||
const interval = setInterval(fetchQueue, 200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return queueInfo;
|
||||
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
|
||||
is_downloading: false,
|
||||
queue: [],
|
||||
current_speed: 0,
|
||||
total_downloaded: 0,
|
||||
session_start_time: 0,
|
||||
queued_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
skipped_count: 0,
|
||||
}));
|
||||
useEffect(() => {
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to get download queue:", error);
|
||||
}
|
||||
};
|
||||
fetchQueue();
|
||||
const interval = setInterval(fetchQueue, 200);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
return queueInfo;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function useDownloadQueueDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const openQueue = () => setIsOpen(true);
|
||||
const closeQueue = () => setIsOpen(false);
|
||||
const toggleQueue = () => setIsOpen((prev) => !prev);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
openQueue,
|
||||
closeQueue,
|
||||
toggleQueue,
|
||||
};
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const openQueue = () => setIsOpen(true);
|
||||
const closeQueue = () => setIsOpen(false);
|
||||
const toggleQueue = () => setIsOpen((prev) => !prev);
|
||||
return {
|
||||
isOpen,
|
||||
openQueue,
|
||||
closeQueue,
|
||||
toggleQueue,
|
||||
};
|
||||
}
|
||||
|
||||
+198
-251
@@ -5,260 +5,207 @@ import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
export function useLyrics() {
|
||||
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
|
||||
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
|
||||
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
|
||||
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
|
||||
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
|
||||
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
|
||||
const stopBulkDownloadRef = useRef(false);
|
||||
|
||||
const handleDownloadLyrics = async (
|
||||
spotifyId: string,
|
||||
trackName: string,
|
||||
artistName: string,
|
||||
albumName?: string,
|
||||
playlistName?: string,
|
||||
position?: number,
|
||||
albumArtist?: string,
|
||||
releaseDate?: string,
|
||||
discNumber?: number,
|
||||
isAlbum?: boolean // Add isAlbum parameter
|
||||
) => {
|
||||
if (!spotifyId) {
|
||||
toast.error("No Spotify ID found for this track");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
|
||||
const settings = getSettings();
|
||||
setDownloadingLyricsTrack(spotifyId);
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Build output path using template system
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
// Only do this if it's NOT an album download
|
||||
if (playlistName && !isAlbum) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
|
||||
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
|
||||
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
|
||||
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
|
||||
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
|
||||
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
|
||||
const stopBulkDownloadRef = useRef(false);
|
||||
const handleDownloadLyrics = async (spotifyId: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
|
||||
if (!spotifyId) {
|
||||
toast.error("No Spotify ID found for this track");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: spotifyId,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: position || 0,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
disc_number: discNumber,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info("Lyrics file already exists");
|
||||
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
} else {
|
||||
toast.success("Lyrics downloaded successfully");
|
||||
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
}
|
||||
setFailedLyrics((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(spotifyId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(response.error || "Failed to download lyrics");
|
||||
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
|
||||
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
} finally {
|
||||
setDownloadingLyricsTrack(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAllLyrics = async (
|
||||
tracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
_isArtistDiscography?: boolean,
|
||||
isAlbum?: boolean // Add isAlbum parameter
|
||||
) => {
|
||||
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
|
||||
|
||||
if (tracksWithSpotifyId.length === 0) {
|
||||
toast.error("No tracks with Spotify ID available for lyrics download");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setIsBulkDownloadingLyrics(true);
|
||||
setLyricsDownloadProgress(0);
|
||||
stopBulkDownloadRef.current = false;
|
||||
|
||||
let completed = 0;
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const total = tracksWithSpotifyId.length;
|
||||
|
||||
for (let i = 0; i < tracksWithSpotifyId.length; i++) {
|
||||
const track = tracksWithSpotifyId[i];
|
||||
if (stopBulkDownloadRef.current) {
|
||||
toast.info("Lyrics download stopped by user");
|
||||
break;
|
||||
}
|
||||
|
||||
const id = track.spotify_id!;
|
||||
setDownloadingLyricsTrack(id);
|
||||
setLyricsDownloadProgress(Math.round((completed / total) * 100));
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
|
||||
// Determine if we should use album track number or sequential position
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
|
||||
// Build output path using template system
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
// Only do this if it's NOT an album download
|
||||
if (playlistName && !isAlbum) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
|
||||
const settings = getSettings();
|
||||
setDownloadingLyricsTrack(spotifyId);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
if (playlistName && !isAlbum) {
|
||||
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) {
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: spotifyId,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
album_artist: albumArtist,
|
||||
release_date: releaseDate,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: position || 0,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
disc_number: discNumber,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info("Lyrics file already exists");
|
||||
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
}
|
||||
else {
|
||||
toast.success("Lyrics downloaded successfully");
|
||||
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
}
|
||||
setFailedLyrics((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(spotifyId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download lyrics");
|
||||
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: id,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
album_name: track.album_name,
|
||||
album_artist: track.album_artist,
|
||||
release_date: track.release_date,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: trackPosition,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
disc_number: track.disc_number,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skipped++;
|
||||
setSkippedLyrics((prev) => new Set(prev).add(id));
|
||||
} else {
|
||||
success++;
|
||||
setDownloadedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
setFailedLyrics((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
|
||||
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
logger.error(`error downloading lyrics: ${track.name} - ${err}`);
|
||||
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
|
||||
completed++;
|
||||
}
|
||||
|
||||
setDownloadingLyricsTrack(null);
|
||||
setIsBulkDownloadingLyrics(false);
|
||||
setLyricsDownloadProgress(0);
|
||||
|
||||
if (!stopBulkDownloadRef.current) {
|
||||
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopLyricsDownload = () => {
|
||||
logger.info("lyrics download stopped by user");
|
||||
stopBulkDownloadRef.current = true;
|
||||
toast.info("Stopping lyrics download...");
|
||||
};
|
||||
|
||||
const resetLyricsState = () => {
|
||||
setDownloadedLyrics(new Set());
|
||||
setFailedLyrics(new Set());
|
||||
setSkippedLyrics(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
downloadingLyricsTrack,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
isBulkDownloadingLyrics,
|
||||
lyricsDownloadProgress,
|
||||
handleDownloadLyrics,
|
||||
handleDownloadAllLyrics,
|
||||
handleStopLyricsDownload,
|
||||
resetLyricsState,
|
||||
};
|
||||
finally {
|
||||
setDownloadingLyricsTrack(null);
|
||||
}
|
||||
};
|
||||
const handleDownloadAllLyrics = async (tracks: TrackMetadata[], playlistName?: string, _isArtistDiscography?: boolean, isAlbum?: boolean) => {
|
||||
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
|
||||
if (tracksWithSpotifyId.length === 0) {
|
||||
toast.error("No tracks with Spotify ID available for lyrics download");
|
||||
return;
|
||||
}
|
||||
const settings = getSettings();
|
||||
setIsBulkDownloadingLyrics(true);
|
||||
setLyricsDownloadProgress(0);
|
||||
stopBulkDownloadRef.current = false;
|
||||
let completed = 0;
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const total = tracksWithSpotifyId.length;
|
||||
for (let i = 0; i < tracksWithSpotifyId.length; i++) {
|
||||
const track = tracksWithSpotifyId[i];
|
||||
if (stopBulkDownloadRef.current) {
|
||||
toast.info("Lyrics download stopped by user");
|
||||
break;
|
||||
}
|
||||
const id = track.spotify_id!;
|
||||
setDownloadingLyricsTrack(id);
|
||||
setLyricsDownloadProgress(Math.round((completed / total) * 100));
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists?.replace(/\//g, placeholder),
|
||||
album: track.album_name?.replace(/\//g, placeholder),
|
||||
title: track.name?.replace(/\//g, placeholder),
|
||||
track: trackPosition,
|
||||
playlist: playlistName?.replace(/\//g, placeholder),
|
||||
};
|
||||
if (playlistName && !isAlbum) {
|
||||
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) {
|
||||
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: id,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
album_name: track.album_name,
|
||||
album_artist: track.album_artist,
|
||||
release_date: track.release_date,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: trackPosition,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
disc_number: track.disc_number,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skipped++;
|
||||
setSkippedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
else {
|
||||
success++;
|
||||
setDownloadedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
setFailedLyrics((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
else {
|
||||
failed++;
|
||||
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
failed++;
|
||||
logger.error(`error downloading lyrics: ${track.name} - ${err}`);
|
||||
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
setDownloadingLyricsTrack(null);
|
||||
setIsBulkDownloadingLyrics(false);
|
||||
setLyricsDownloadProgress(0);
|
||||
if (!stopBulkDownloadRef.current) {
|
||||
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||
}
|
||||
};
|
||||
const handleStopLyricsDownload = () => {
|
||||
logger.info("lyrics download stopped by user");
|
||||
stopBulkDownloadRef.current = true;
|
||||
toast.info("Stopping lyrics download...");
|
||||
};
|
||||
const resetLyricsState = () => {
|
||||
setDownloadedLyrics(new Set());
|
||||
setFailedLyrics(new Set());
|
||||
setSkippedLyrics(new Set());
|
||||
};
|
||||
return {
|
||||
downloadingLyricsTrack,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
isBulkDownloadingLyrics,
|
||||
lyricsDownloadProgress,
|
||||
handleDownloadLyrics,
|
||||
handleDownloadAllLyrics,
|
||||
handleStopLyricsDownload,
|
||||
resetLyricsState,
|
||||
};
|
||||
}
|
||||
|
||||
+210
-197
@@ -3,202 +3,215 @@ import { fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||
|
||||
export function useMetadata() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
||||
const [timeoutValue, setTimeoutValue] = useState(60);
|
||||
const [pendingUrl, setPendingUrl] = useState("");
|
||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
} | null>(null);
|
||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||
|
||||
const getUrlType = (url: string): string => {
|
||||
if (url.includes("/track/")) return "track";
|
||||
if (url.includes("/album/")) return "album";
|
||||
if (url.includes("/playlist/")) return "playlist";
|
||||
if (url.includes("/artist/")) return "artist";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const fetchMetadataDirectly = async (url: string) => {
|
||||
const urlType = getUrlType(url);
|
||||
logger.info(`fetching ${urlType} metadata...`);
|
||||
logger.debug(`url: ${url}`);
|
||||
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(url);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
// Log detailed info based on type
|
||||
if ("track" in data) {
|
||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||
} else if ("album_info" in data) {
|
||||
logger.success(`fetched album: ${data.album_info.name}`);
|
||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||
} else if ("playlist_info" in data) {
|
||||
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
|
||||
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
|
||||
} else if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchMetadata = async (url: string) => {
|
||||
if (!url.trim()) {
|
||||
logger.warning("empty url provided");
|
||||
toast.error("Please enter a Spotify URL");
|
||||
return;
|
||||
}
|
||||
|
||||
let urlToFetch = url.trim();
|
||||
const isArtistUrl = urlToFetch.includes("/artist/");
|
||||
|
||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||
logger.debug("converted to discography url");
|
||||
}
|
||||
|
||||
if (isArtistUrl) {
|
||||
logger.info("artist url detected, showing timeout dialog");
|
||||
setPendingUrl(urlToFetch);
|
||||
setPendingArtistName(null); // Clear artist name for URL input
|
||||
setShowTimeoutDialog(true);
|
||||
} else {
|
||||
await fetchMetadataDirectly(urlToFetch);
|
||||
}
|
||||
|
||||
return urlToFetch;
|
||||
};
|
||||
|
||||
const handleConfirmFetch = async () => {
|
||||
setShowTimeoutDialog(false);
|
||||
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
||||
logger.debug(`url: ${pendingUrl}`);
|
||||
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlbumClick = (album: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`album clicked: ${album.name}`);
|
||||
setSelectedAlbum(album);
|
||||
setShowAlbumDialog(true);
|
||||
};
|
||||
|
||||
const handleArtistClick = async (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`artist clicked: ${artist.name}`);
|
||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setPendingUrl(artistUrl);
|
||||
setPendingArtistName(artist.name);
|
||||
setShowTimeoutDialog(true);
|
||||
return artistUrl;
|
||||
};
|
||||
|
||||
const handleConfirmAlbumFetch = async () => {
|
||||
if (!selectedAlbum) return;
|
||||
|
||||
const albumUrl = selectedAlbum.external_urls;
|
||||
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
||||
logger.debug(`url: ${albumUrl}`);
|
||||
|
||||
setShowAlbumDialog(false);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(albumUrl);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
if ("album_info" in data) {
|
||||
logger.success(`fetched album: ${data.album_info.name}`);
|
||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Album metadata fetched successfully");
|
||||
return albumUrl;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSelectedAlbum(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
metadata,
|
||||
showTimeoutDialog,
|
||||
setShowTimeoutDialog,
|
||||
timeoutValue,
|
||||
setTimeoutValue,
|
||||
showAlbumDialog,
|
||||
setShowAlbumDialog,
|
||||
selectedAlbum,
|
||||
pendingArtistName,
|
||||
handleFetchMetadata,
|
||||
handleConfirmFetch,
|
||||
handleAlbumClick,
|
||||
handleConfirmAlbumFetch,
|
||||
handleArtistClick,
|
||||
};
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
||||
const [timeoutValue, setTimeoutValue] = useState(60);
|
||||
const [pendingUrl, setPendingUrl] = useState("");
|
||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
} | null>(null);
|
||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||
const getUrlType = (url: string): string => {
|
||||
if (url.includes("/track/"))
|
||||
return "track";
|
||||
if (url.includes("/album/"))
|
||||
return "album";
|
||||
if (url.includes("/playlist/"))
|
||||
return "playlist";
|
||||
if (url.includes("/artist/"))
|
||||
return "artist";
|
||||
return "unknown";
|
||||
};
|
||||
const fetchMetadataDirectly = async (url: string) => {
|
||||
const urlType = getUrlType(url);
|
||||
logger.info(`fetching ${urlType} metadata...`);
|
||||
logger.debug(`url: ${url}`);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(url);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
if ("playlist_info" in data) {
|
||||
const playlistInfo = data.playlist_info;
|
||||
if (!playlistInfo.owner.name && playlistInfo.tracks.total === 0 && data.track_list.length === 0) {
|
||||
logger.warning("playlist appears to be empty or private");
|
||||
toast.error("Playlist not found or may be private");
|
||||
setMetadata(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if ("album_info" in data) {
|
||||
const albumInfo = data.album_info;
|
||||
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
|
||||
logger.warning("album appears to be empty or not found");
|
||||
toast.error("Album not found or may be private");
|
||||
setMetadata(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMetadata(data);
|
||||
if ("track" in data) {
|
||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||
}
|
||||
else if ("album_info" in data) {
|
||||
logger.success(`fetched album: ${data.album_info.name}`);
|
||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||
}
|
||||
else if ("playlist_info" in data) {
|
||||
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
|
||||
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
|
||||
}
|
||||
else if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
}
|
||||
catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleFetchMetadata = async (url: string) => {
|
||||
if (!url.trim()) {
|
||||
logger.warning("empty url provided");
|
||||
toast.error("Please enter a Spotify URL");
|
||||
return;
|
||||
}
|
||||
let urlToFetch = url.trim();
|
||||
const isArtistUrl = urlToFetch.includes("/artist/");
|
||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||
logger.debug("converted to discography url");
|
||||
}
|
||||
if (isArtistUrl) {
|
||||
logger.info("artist url detected, showing timeout dialog");
|
||||
setPendingUrl(urlToFetch);
|
||||
setPendingArtistName(null);
|
||||
setShowTimeoutDialog(true);
|
||||
}
|
||||
else {
|
||||
await fetchMetadataDirectly(urlToFetch);
|
||||
}
|
||||
return urlToFetch;
|
||||
};
|
||||
const handleConfirmFetch = async () => {
|
||||
setShowTimeoutDialog(false);
|
||||
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
||||
logger.debug(`url: ${pendingUrl}`);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
setMetadata(data);
|
||||
if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
}
|
||||
catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleAlbumClick = (album: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`album clicked: ${album.name}`);
|
||||
setSelectedAlbum(album);
|
||||
setShowAlbumDialog(true);
|
||||
};
|
||||
const handleArtistClick = async (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`artist clicked: ${artist.name}`);
|
||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setPendingUrl(artistUrl);
|
||||
setPendingArtistName(artist.name);
|
||||
setShowTimeoutDialog(true);
|
||||
return artistUrl;
|
||||
};
|
||||
const handleConfirmAlbumFetch = async () => {
|
||||
if (!selectedAlbum)
|
||||
return;
|
||||
const albumUrl = selectedAlbum.external_urls;
|
||||
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
||||
logger.debug(`url: ${albumUrl}`);
|
||||
setShowAlbumDialog(false);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(albumUrl);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
if ("album_info" in data) {
|
||||
const albumInfo = data.album_info;
|
||||
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
|
||||
logger.warning("album appears to be empty or not found");
|
||||
toast.error("Album not found or may be private");
|
||||
setMetadata(null);
|
||||
setSelectedAlbum(null);
|
||||
return albumUrl;
|
||||
}
|
||||
}
|
||||
setMetadata(data);
|
||||
if ("album_info" in data) {
|
||||
logger.success(`fetched album: ${data.album_info.name}`);
|
||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||
}
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Album metadata fetched successfully");
|
||||
return albumUrl;
|
||||
}
|
||||
catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
setSelectedAlbum(null);
|
||||
}
|
||||
};
|
||||
return {
|
||||
loading,
|
||||
metadata,
|
||||
showTimeoutDialog,
|
||||
setShowTimeoutDialog,
|
||||
timeoutValue,
|
||||
setTimeoutValue,
|
||||
showAlbumDialog,
|
||||
setShowAlbumDialog,
|
||||
selectedAlbum,
|
||||
pendingArtistName,
|
||||
handleFetchMetadata,
|
||||
handleConfirmFetch,
|
||||
handleAlbumClick,
|
||||
handleConfirmAlbumFetch,
|
||||
handleArtistClick,
|
||||
};
|
||||
}
|
||||
|
||||
+36
-52
@@ -1,59 +1,43 @@
|
||||
import type {
|
||||
SpotifyMetadataResponse,
|
||||
DownloadRequest,
|
||||
DownloadResponse,
|
||||
HealthResponse,
|
||||
LyricsDownloadRequest,
|
||||
LyricsDownloadResponse,
|
||||
CoverDownloadRequest,
|
||||
CoverDownloadResponse,
|
||||
} from "@/types/api";
|
||||
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
|
||||
import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api";
|
||||
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App";
|
||||
import { main } from "../../wailsjs/go/models";
|
||||
|
||||
export async function fetchSpotifyMetadata(
|
||||
url: string,
|
||||
batch: boolean = true,
|
||||
delay: number = 1.0,
|
||||
timeout: number = 300.0
|
||||
): Promise<SpotifyMetadataResponse> {
|
||||
const req = new main.SpotifyMetadataRequest({
|
||||
url,
|
||||
batch,
|
||||
delay,
|
||||
timeout,
|
||||
});
|
||||
|
||||
const jsonString = await GetSpotifyMetadata(req);
|
||||
return JSON.parse(jsonString);
|
||||
export async function fetchSpotifyMetadata(url: string, batch: boolean = true, delay: number = 1.0, timeout: number = 300.0): Promise<SpotifyMetadataResponse> {
|
||||
const req = new main.SpotifyMetadataRequest({
|
||||
url,
|
||||
batch,
|
||||
delay,
|
||||
timeout,
|
||||
});
|
||||
const jsonString = await GetSpotifyMetadata(req);
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
export async function downloadTrack(
|
||||
request: DownloadRequest
|
||||
): Promise<DownloadResponse> {
|
||||
const req = new main.DownloadRequest(request);
|
||||
return await DownloadTrack(req);
|
||||
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||
const req = new main.DownloadRequest(request);
|
||||
return await DownloadTrack(req);
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<HealthResponse> {
|
||||
// For Wails, we can just return a simple health check
|
||||
// since the app is running locally
|
||||
return {
|
||||
status: "ok",
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
return {
|
||||
status: "ok",
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadLyrics(
|
||||
request: LyricsDownloadRequest
|
||||
): Promise<LyricsDownloadResponse> {
|
||||
const req = new main.LyricsDownloadRequest(request);
|
||||
return await DownloadLyrics(req);
|
||||
export async function downloadLyrics(request: LyricsDownloadRequest): Promise<LyricsDownloadResponse> {
|
||||
const req = new main.LyricsDownloadRequest(request);
|
||||
return await DownloadLyrics(req);
|
||||
}
|
||||
|
||||
export async function downloadCover(
|
||||
request: CoverDownloadRequest
|
||||
): Promise<CoverDownloadResponse> {
|
||||
const req = new main.CoverDownloadRequest(request);
|
||||
return await DownloadCover(req);
|
||||
export async function downloadCover(request: CoverDownloadRequest): Promise<CoverDownloadResponse> {
|
||||
const req = new main.CoverDownloadRequest(request);
|
||||
return await DownloadCover(req);
|
||||
}
|
||||
export async function downloadHeader(request: HeaderDownloadRequest): Promise<HeaderDownloadResponse> {
|
||||
const req = new main.HeaderDownloadRequest(request);
|
||||
return await DownloadHeader(req);
|
||||
}
|
||||
export async function downloadGalleryImage(request: GalleryImageDownloadRequest): Promise<GalleryImageDownloadResponse> {
|
||||
const req = new main.GalleryImageDownloadRequest(request);
|
||||
return await DownloadGalleryImage(req);
|
||||
}
|
||||
export async function downloadAvatar(request: AvatarDownloadRequest): Promise<AvatarDownloadResponse> {
|
||||
const req = new main.AvatarDownloadRequest(request);
|
||||
return await DownloadAvatar(req);
|
||||
}
|
||||
|
||||
+62
-97
@@ -1,106 +1,71 @@
|
||||
// Audio utility for toast notifications using Web Audio API
|
||||
|
||||
class AudioManager {
|
||||
private audioContext: AudioContext | null = null;
|
||||
|
||||
private getAudioContext(): AudioContext {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
private audioContext: AudioContext | null = null;
|
||||
private getAudioContext(): AudioContext {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
return this.audioContext;
|
||||
}
|
||||
return this.audioContext;
|
||||
}
|
||||
|
||||
// Generate a simple tone using oscillator
|
||||
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
|
||||
try {
|
||||
const ctx = this.getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.value = frequency;
|
||||
oscillator.type = type;
|
||||
|
||||
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + duration);
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
|
||||
try {
|
||||
const ctx = this.getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
oscillator.frequency.value = frequency;
|
||||
oscillator.type = type;
|
||||
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + duration);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Success sound - pleasant ascending tones
|
||||
playSuccess() {
|
||||
const ctx = this.getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// First tone
|
||||
this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5
|
||||
// Second tone
|
||||
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5
|
||||
// Third tone
|
||||
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5
|
||||
}
|
||||
|
||||
// Error sound - descending tones
|
||||
playError() {
|
||||
const ctx = this.getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// First tone
|
||||
this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4
|
||||
// Second tone
|
||||
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4
|
||||
}
|
||||
|
||||
// Warning sound - alternating tones
|
||||
playWarning() {
|
||||
const ctx = this.getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// First tone
|
||||
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4
|
||||
// Second tone
|
||||
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4
|
||||
}
|
||||
|
||||
// Info sound - single soft tone
|
||||
playInfo() {
|
||||
this.playTone(523.25, 0.15, 'sine', 0.15); // C5
|
||||
}
|
||||
|
||||
// Helper method to play tone at specific time
|
||||
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
|
||||
try {
|
||||
const ctx = this.getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.value = frequency;
|
||||
oscillator.type = type;
|
||||
|
||||
gainNode.gain.setValueAtTime(volume, startTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + duration);
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
playSuccess() {
|
||||
const ctx = this.getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
this.playToneAt(523.25, 0.08, 'sine', 0.2, now);
|
||||
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08);
|
||||
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16);
|
||||
}
|
||||
playError() {
|
||||
const ctx = this.getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
this.playToneAt(392.00, 0.1, 'square', 0.15, now);
|
||||
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1);
|
||||
}
|
||||
playWarning() {
|
||||
const ctx = this.getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now);
|
||||
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12);
|
||||
}
|
||||
playInfo() {
|
||||
this.playTone(523.25, 0.15, 'sine', 0.15);
|
||||
}
|
||||
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
|
||||
try {
|
||||
const ctx = this.getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
oscillator.frequency.value = frequency;
|
||||
oscillator.type = type;
|
||||
gainNode.gain.setValueAtTime(volume, startTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + duration);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const audioManager = new AudioManager();
|
||||
|
||||
// Helper functions for easy use
|
||||
export const playSuccessSound = () => audioManager.playSuccess();
|
||||
export const playErrorSound = () => audioManager.playError();
|
||||
export const playWarningSound = () => audioManager.playWarning();
|
||||
|
||||
+46
-59
@@ -1,66 +1,53 @@
|
||||
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private logs: LogEntry[] = [];
|
||||
private maxLogs = 500;
|
||||
private listeners: Set<() => void> = new Set();
|
||||
|
||||
private addLog(level: LogLevel, message: string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message: message.toLowerCase(),
|
||||
};
|
||||
this.logs.push(entry);
|
||||
if (this.logs.length > this.maxLogs) {
|
||||
this.logs.shift();
|
||||
private logs: LogEntry[] = [];
|
||||
private maxLogs = 500;
|
||||
private listeners: Set<() => void> = new Set();
|
||||
private addLog(level: LogLevel, message: string) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message: message.toLowerCase(),
|
||||
};
|
||||
this.logs.push(entry);
|
||||
if (this.logs.length > this.maxLogs) {
|
||||
this.logs.shift();
|
||||
}
|
||||
this.notifyListeners();
|
||||
}
|
||||
info(message: string) {
|
||||
this.addLog("info", message);
|
||||
}
|
||||
success(message: string) {
|
||||
this.addLog("success", message);
|
||||
}
|
||||
warning(message: string) {
|
||||
this.addLog("warning", message);
|
||||
}
|
||||
error(message: string) {
|
||||
this.addLog("error", message);
|
||||
}
|
||||
debug(message: string) {
|
||||
this.addLog("debug", message);
|
||||
}
|
||||
getLogs(): LogEntry[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
clear() {
|
||||
this.logs = [];
|
||||
this.notifyListeners();
|
||||
}
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
private notifyListeners() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
info(message: string) {
|
||||
this.addLog("info", message);
|
||||
}
|
||||
|
||||
success(message: string) {
|
||||
this.addLog("success", message);
|
||||
}
|
||||
|
||||
warning(message: string) {
|
||||
this.addLog("warning", message);
|
||||
}
|
||||
|
||||
error(message: string) {
|
||||
this.addLog("error", message);
|
||||
}
|
||||
|
||||
debug(message: string) {
|
||||
this.addLog("debug", message);
|
||||
}
|
||||
|
||||
getLogs(): LogEntry[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.logs = [];
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
/**
|
||||
* Format a date to relative time string with max 2 units
|
||||
* e.g., "23 hours 32 minutes ago", "1 day 14 hours ago"
|
||||
*/
|
||||
export function formatRelativeTime(date: Date | string | number): string {
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = now.getTime() - target.getTime();
|
||||
|
||||
if (diffMs < 0) return "just now";
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(days / 365);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (years > 0) {
|
||||
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
|
||||
const remainingMonths = Math.floor((days % 365) / 30);
|
||||
if (remainingMonths > 0) {
|
||||
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = now.getTime() - target.getTime();
|
||||
if (diffMs < 0)
|
||||
return "just now";
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(days / 365);
|
||||
const parts: string[] = [];
|
||||
if (years > 0) {
|
||||
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
|
||||
const remainingMonths = Math.floor((days % 365) / 30);
|
||||
if (remainingMonths > 0) {
|
||||
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
|
||||
}
|
||||
}
|
||||
} else if (months > 0) {
|
||||
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
|
||||
const remainingDays = days % 30;
|
||||
if (remainingDays > 0) {
|
||||
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||
else if (months > 0) {
|
||||
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
|
||||
const remainingDays = days % 30;
|
||||
if (remainingDays > 0) {
|
||||
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||
}
|
||||
}
|
||||
} else if (weeks > 0) {
|
||||
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
|
||||
const remainingDays = days % 7;
|
||||
if (remainingDays > 0) {
|
||||
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||
else if (weeks > 0) {
|
||||
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
|
||||
const remainingDays = days % 7;
|
||||
if (remainingDays > 0) {
|
||||
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||
}
|
||||
}
|
||||
} else if (days > 0) {
|
||||
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours > 0) {
|
||||
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
|
||||
else if (days > 0) {
|
||||
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours > 0) {
|
||||
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
|
||||
}
|
||||
}
|
||||
} else if (hours > 0) {
|
||||
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes > 0) {
|
||||
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
|
||||
else if (hours > 0) {
|
||||
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes > 0) {
|
||||
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
|
||||
}
|
||||
}
|
||||
} else if (minutes > 0) {
|
||||
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
||||
} else {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
return "Released " + parts.slice(0, 2).join(" ") + " ago";
|
||||
else if (minutes > 0) {
|
||||
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
||||
}
|
||||
else {
|
||||
return "just now";
|
||||
}
|
||||
return "Released " + parts.slice(0, 2).join(" ") + " ago";
|
||||
}
|
||||
|
||||
+238
-256
@@ -1,287 +1,269 @@
|
||||
import { GetDefaults } from "../../wailsjs/go/main/App";
|
||||
|
||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans";
|
||||
|
||||
// Folder structure presets
|
||||
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
||||
|
||||
// Filename format presets
|
||||
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";
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
// New template system
|
||||
folderPreset: FolderPreset;
|
||||
folderTemplate: string;
|
||||
filenamePreset: FilenamePreset;
|
||||
filenameTemplate: string;
|
||||
// Legacy settings (kept for migration)
|
||||
filenameFormat?: "title-artist" | "artist-title" | "title";
|
||||
artistSubfolder?: boolean;
|
||||
albumSubfolder?: boolean;
|
||||
trackNumber: boolean;
|
||||
sfxEnabled: boolean;
|
||||
embedLyrics: boolean;
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
// Quality settings for specific sources
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
folderPreset: FolderPreset;
|
||||
folderTemplate: string;
|
||||
filenamePreset: FilenamePreset;
|
||||
filenameTemplate: string;
|
||||
filenameFormat?: "title-artist" | "artist-title" | "title";
|
||||
artistSubfolder?: boolean;
|
||||
albumSubfolder?: boolean;
|
||||
trackNumber: boolean;
|
||||
sfxEnabled: boolean;
|
||||
embedLyrics: boolean;
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
amazonQuality: "HI_RES";
|
||||
}
|
||||
|
||||
// Folder preset templates
|
||||
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = {
|
||||
"none": { label: "No Subfolder", template: "" },
|
||||
"artist": { label: "Artist", template: "{artist}" },
|
||||
"album": { label: "Album", template: "{album}" },
|
||||
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
||||
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
|
||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
||||
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
|
||||
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
||||
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
|
||||
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
|
||||
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
|
||||
"year": { label: "Year", template: "{year}" },
|
||||
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
|
||||
"custom": { label: "Custom...", template: "{artist}/{album}" },
|
||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||
label: string;
|
||||
template: string;
|
||||
}> = {
|
||||
"none": { label: "No Subfolder", template: "" },
|
||||
"artist": { label: "Artist", template: "{artist}" },
|
||||
"album": { label: "Album", template: "{album}" },
|
||||
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
||||
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
|
||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
||||
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
|
||||
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
||||
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
|
||||
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
|
||||
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
|
||||
"year": { label: "Year", template: "{year}" },
|
||||
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
|
||||
"custom": { label: "Custom...", template: "{artist}/{album}" },
|
||||
};
|
||||
|
||||
// Filename preset templates
|
||||
export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template: string }> = {
|
||||
"title": { label: "Title", template: "{title}" },
|
||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
||||
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
|
||||
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
|
||||
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
||||
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
||||
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
||||
export const FILENAME_PRESETS: Record<FilenamePreset, {
|
||||
label: string;
|
||||
template: string;
|
||||
}> = {
|
||||
"title": { label: "Title", template: "{title}" },
|
||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
||||
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
|
||||
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
|
||||
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
||||
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
||||
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
||||
};
|
||||
|
||||
// Available template variables
|
||||
export const TEMPLATE_VARIABLES = [
|
||||
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
||||
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
||||
{ key: "{album}", description: "Album name", example: "1989" },
|
||||
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
|
||||
{ key: "{track}", description: "Track number", example: "01" },
|
||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||
{ key: "{year}", description: "Release year", example: "2014" },
|
||||
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
||||
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
||||
{ key: "{album}", description: "Album name", example: "1989" },
|
||||
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
|
||||
{ key: "{track}", description: "Track number", example: "01" },
|
||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||
{ key: "{year}", description: "Release year", example: "2014" },
|
||||
];
|
||||
|
||||
// Auto-detect operating system
|
||||
function detectOS(): "Windows" | "linux/MacOS" {
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
if (platform.includes('win')) {
|
||||
return "Windows";
|
||||
}
|
||||
return "linux/MacOS";
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
if (platform.includes('win')) {
|
||||
return "Windows";
|
||||
}
|
||||
return "linux/MacOS";
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
downloadPath: "",
|
||||
downloader: "auto",
|
||||
theme: "yellow",
|
||||
themeMode: "auto",
|
||||
fontFamily: "google-sans",
|
||||
folderPreset: "none",
|
||||
folderTemplate: "",
|
||||
filenamePreset: "title-artist",
|
||||
filenameTemplate: "{title} - {artist}",
|
||||
trackNumber: false,
|
||||
sfxEnabled: true,
|
||||
embedLyrics: false,
|
||||
embedMaxQualityCover: false,
|
||||
operatingSystem: detectOS(),
|
||||
tidalQuality: "LOSSLESS", // Default: 16-bit lossless
|
||||
qobuzQuality: "6" // Default: FLAC 16-bit
|
||||
downloadPath: "",
|
||||
downloader: "auto",
|
||||
theme: "yellow",
|
||||
themeMode: "auto",
|
||||
fontFamily: "google-sans",
|
||||
folderPreset: "none",
|
||||
folderTemplate: "",
|
||||
filenamePreset: "title-artist",
|
||||
filenameTemplate: "{title} - {artist}",
|
||||
trackNumber: false,
|
||||
sfxEnabled: true,
|
||||
embedLyrics: false,
|
||||
embedMaxQualityCover: false,
|
||||
operatingSystem: detectOS(),
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "HI_RES"
|
||||
};
|
||||
|
||||
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||
export const FONT_OPTIONS: {
|
||||
value: FontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
}[] = [
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||
];
|
||||
|
||||
export function applyFont(fontFamily: FontFamily): void {
|
||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||
if (font) {
|
||||
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
||||
document.body.style.fontFamily = font.fontFamily;
|
||||
}
|
||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||
if (font) {
|
||||
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
||||
document.body.style.fontFamily = font.fontFamily;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDefaultPath(): Promise<string> {
|
||||
try {
|
||||
const data = await GetDefaults();
|
||||
return data.downloadPath || "";
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch default path:", error);
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const data = await GetDefaults();
|
||||
return data.downloadPath || "";
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to fetch default path:", error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = "spotiflac-settings";
|
||||
|
||||
export function getSettings(): Settings {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Migrate old darkMode to themeMode
|
||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||
delete parsed.darkMode;
|
||||
}
|
||||
// Migrate old folder/filename settings to new template system
|
||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
||||
const hasArtist = parsed.artistSubfolder;
|
||||
const hasAlbum = parsed.albumSubfolder;
|
||||
if (hasArtist && hasAlbum) {
|
||||
parsed.folderPreset = "artist-album";
|
||||
parsed.folderTemplate = "{artist}/{album}";
|
||||
} else if (hasArtist) {
|
||||
parsed.folderPreset = "artist";
|
||||
parsed.folderTemplate = "{artist}";
|
||||
} else if (hasAlbum) {
|
||||
parsed.folderPreset = "album";
|
||||
parsed.folderTemplate = "{album}";
|
||||
} else {
|
||||
parsed.folderPreset = "none";
|
||||
parsed.folderTemplate = "";
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||
delete parsed.darkMode;
|
||||
}
|
||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
||||
const hasArtist = parsed.artistSubfolder;
|
||||
const hasAlbum = parsed.albumSubfolder;
|
||||
if (hasArtist && hasAlbum) {
|
||||
parsed.folderPreset = "artist-album";
|
||||
parsed.folderTemplate = "{artist}/{album}";
|
||||
}
|
||||
else if (hasArtist) {
|
||||
parsed.folderPreset = "artist";
|
||||
parsed.folderTemplate = "{artist}";
|
||||
}
|
||||
else if (hasAlbum) {
|
||||
parsed.folderPreset = "album";
|
||||
parsed.folderTemplate = "{album}";
|
||||
}
|
||||
else {
|
||||
parsed.folderPreset = "none";
|
||||
parsed.folderTemplate = "";
|
||||
}
|
||||
}
|
||||
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
||||
const format = parsed.filenameFormat;
|
||||
if (format === "title-artist") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else if (format === "artist-title") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else {
|
||||
parsed.filenamePreset = "title";
|
||||
parsed.filenameTemplate = "{title}";
|
||||
}
|
||||
}
|
||||
parsed.operatingSystem = detectOS();
|
||||
if (!('tidalQuality' in parsed)) {
|
||||
parsed.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "HI_RES";
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
}
|
||||
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
||||
const format = parsed.filenameFormat;
|
||||
if (format === "title-artist") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
} else if (format === "artist-title") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
} else {
|
||||
parsed.filenamePreset = "title";
|
||||
parsed.filenameTemplate = "{title}";
|
||||
}
|
||||
}
|
||||
// Always use detected OS (don't persist it)
|
||||
parsed.operatingSystem = detectOS();
|
||||
// Set default quality if not present
|
||||
if (!('tidalQuality' in parsed)) {
|
||||
parsed.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
// Parse template and replace variables with actual values
|
||||
export interface TemplateData {
|
||||
artist?: string;
|
||||
album?: string;
|
||||
album_artist?: string;
|
||||
title?: string;
|
||||
track?: number;
|
||||
disc?: number;
|
||||
year?: string;
|
||||
isrc?: string;
|
||||
playlist?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
album_artist?: string;
|
||||
title?: string;
|
||||
track?: number;
|
||||
disc?: number;
|
||||
year?: string;
|
||||
playlist?: string;
|
||||
}
|
||||
|
||||
export function parseTemplate(template: string, data: TemplateData): string {
|
||||
if (!template) return "";
|
||||
|
||||
let result = template;
|
||||
|
||||
// Replace each variable
|
||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
|
||||
result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist");
|
||||
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
||||
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||
result = result.replace(/\{isrc\}/g, data.isrc || "");
|
||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||
|
||||
return result;
|
||||
if (!template)
|
||||
return "";
|
||||
let result = template;
|
||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
|
||||
result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist");
|
||||
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
||||
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||
const settings = getSettings();
|
||||
|
||||
// If downloadPath is empty, fetch from backend
|
||||
if (!settings.downloadPath) {
|
||||
settings.downloadPath = await fetchDefaultPath();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function saveSettings(settings: Settings): void {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSettings(partial: Partial<Settings>): Settings {
|
||||
const current = getSettings();
|
||||
const updated = { ...current, ...partial };
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||
const defaultPath = await fetchDefaultPath();
|
||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||
saveSettings(defaultSettings);
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
||||
if (mode === "auto") {
|
||||
// Check system preference
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
const settings = getSettings();
|
||||
if (!settings.downloadPath) {
|
||||
settings.downloadPath = await fetchDefaultPath();
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
export function saveSettings(settings: Settings): void {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
}
|
||||
}
|
||||
export function updateSettings(partial: Partial<Settings>): Settings {
|
||||
const current = getSettings();
|
||||
const updated = { ...current, ...partial };
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||
const defaultPath = await fetchDefaultPath();
|
||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||
saveSettings(defaultSettings);
|
||||
return defaultSettings;
|
||||
}
|
||||
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
||||
if (mode === "auto") {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
else if (mode === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
} else if (mode === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
// Memory cache for spectrum data (fast access, cleared on page refresh)
|
||||
// Key: file path, Value: spectrum data
|
||||
|
||||
const spectrumCache = new Map<string, any>();
|
||||
|
||||
export function setSpectrumCache(filePath: string, spectrumData: any): void {
|
||||
spectrumCache.set(filePath, spectrumData);
|
||||
spectrumCache.set(filePath, spectrumData);
|
||||
}
|
||||
|
||||
export function getSpectrumCache(filePath: string): any | null {
|
||||
return spectrumCache.get(filePath) || null;
|
||||
return spectrumCache.get(filePath) || null;
|
||||
}
|
||||
|
||||
export function clearSpectrumCache(filePath?: string): void {
|
||||
if (filePath) {
|
||||
spectrumCache.delete(filePath);
|
||||
} else {
|
||||
spectrumCache.clear();
|
||||
}
|
||||
if (filePath) {
|
||||
spectrumCache.delete(filePath);
|
||||
}
|
||||
else {
|
||||
spectrumCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+267
-282
@@ -1,290 +1,275 @@
|
||||
export interface Theme {
|
||||
name: string;
|
||||
label: string;
|
||||
cssVars: {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
// Base colors yang sama untuk semua tema (kecuali primary dan primary-foreground)
|
||||
const baseLightColors: Record<string, string> = {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.145 0 0)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.145 0 0)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.145 0 0)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.97 0 0)",
|
||||
"muted-foreground": "oklch(0.556 0 0)",
|
||||
accent: "oklch(0.97 0 0)",
|
||||
"accent-foreground": "oklch(0.205 0 0)",
|
||||
destructive: "oklch(0.58 0.22 27)",
|
||||
border: "oklch(0.922 0 0)",
|
||||
input: "oklch(0.922 0 0)",
|
||||
ring: "oklch(0.708 0 0)",
|
||||
};
|
||||
|
||||
const baseDarkColors: Record<string, string> = {
|
||||
background: "oklch(0.145 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.205 0 0)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
"muted-foreground": "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.371 0 0)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.556 0 0)",
|
||||
};
|
||||
|
||||
// Primary colors yang berbeda untuk setiap tema
|
||||
interface PrimaryColors {
|
||||
light: {
|
||||
primary: string;
|
||||
"primary-foreground": string;
|
||||
};
|
||||
dark: {
|
||||
primary: string;
|
||||
"primary-foreground": string;
|
||||
};
|
||||
}
|
||||
|
||||
const primaryColors: Record<string, PrimaryColors> = {
|
||||
amber: {
|
||||
light: {
|
||||
primary: "oklch(0.67 0.16 58)",
|
||||
"primary-foreground": "oklch(0.99 0.02 95)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.77 0.16 70)",
|
||||
"primary-foreground": "oklch(0.28 0.07 46)",
|
||||
},
|
||||
},
|
||||
blue: {
|
||||
light: {
|
||||
primary: "oklch(0.488 0.243 264.376)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.42 0.18 266)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
},
|
||||
},
|
||||
cyan: {
|
||||
light: {
|
||||
primary: "oklch(0.61 0.11 222)",
|
||||
"primary-foreground": "oklch(0.98 0.02 201)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.71 0.13 215)",
|
||||
"primary-foreground": "oklch(0.30 0.05 230)",
|
||||
},
|
||||
},
|
||||
emerald: {
|
||||
light: {
|
||||
primary: "oklch(0.60 0.13 163)",
|
||||
"primary-foreground": "oklch(0.98 0.02 166)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.70 0.15 162)",
|
||||
"primary-foreground": "oklch(0.26 0.05 173)",
|
||||
},
|
||||
},
|
||||
fuchsia: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.26 323)",
|
||||
"primary-foreground": "oklch(0.98 0.02 320)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.67 0.26 322)",
|
||||
"primary-foreground": "oklch(0.98 0.02 320)",
|
||||
},
|
||||
},
|
||||
green: {
|
||||
light: {
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
},
|
||||
},
|
||||
indigo: {
|
||||
light: {
|
||||
primary: "oklch(0.51 0.23 277)",
|
||||
"primary-foreground": "oklch(0.96 0.02 272)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.59 0.20 277)",
|
||||
"primary-foreground": "oklch(0.96 0.02 272)",
|
||||
},
|
||||
},
|
||||
lime: {
|
||||
light: {
|
||||
primary: "oklch(0.65 0.18 132)",
|
||||
"primary-foreground": "oklch(0.99 0.03 121)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.77 0.20 131)",
|
||||
"primary-foreground": "oklch(0.27 0.07 132)",
|
||||
},
|
||||
},
|
||||
neutral: {
|
||||
light: {
|
||||
primary: "oklch(0.205 0 0)",
|
||||
"primary-foreground": "oklch(0.985 0 0)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.922 0 0)",
|
||||
"primary-foreground": "oklch(0.205 0 0)",
|
||||
},
|
||||
},
|
||||
orange: {
|
||||
light: {
|
||||
primary: "oklch(0.646 0.222 41.116)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.705 0.213 47.604)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
},
|
||||
},
|
||||
pink: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.22 1)",
|
||||
"primary-foreground": "oklch(0.97 0.01 343)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.66 0.21 354)",
|
||||
"primary-foreground": "oklch(0.97 0.01 343)",
|
||||
},
|
||||
},
|
||||
purple: {
|
||||
light: {
|
||||
primary: "oklch(0.56 0.25 302)",
|
||||
"primary-foreground": "oklch(0.98 0.01 308)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.63 0.23 304)",
|
||||
"primary-foreground": "oklch(0.98 0.01 308)",
|
||||
},
|
||||
},
|
||||
red: {
|
||||
light: {
|
||||
primary: "oklch(0.577 0.245 27.325)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.637 0.237 25.331)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
},
|
||||
},
|
||||
rose: {
|
||||
light: {
|
||||
primary: "oklch(0.586 0.253 17.585)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.645 0.246 16.439)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
},
|
||||
},
|
||||
sky: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.14 242)",
|
||||
"primary-foreground": "oklch(0.98 0.01 237)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.68 0.15 237)",
|
||||
"primary-foreground": "oklch(0.29 0.06 243)",
|
||||
},
|
||||
},
|
||||
teal: {
|
||||
light: {
|
||||
primary: "oklch(0.60 0.10 185)",
|
||||
"primary-foreground": "oklch(0.98 0.01 181)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.70 0.12 183)",
|
||||
"primary-foreground": "oklch(0.28 0.04 193)",
|
||||
},
|
||||
},
|
||||
violet: {
|
||||
light: {
|
||||
primary: "oklch(0.541 0.281 293.009)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.606 0.25 292.717)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
},
|
||||
},
|
||||
yellow: {
|
||||
light: {
|
||||
primary: "oklch(0.852 0.199 91.936)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.795 0.184 86.047)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function untuk menggabungkan base colors dengan primary colors
|
||||
function createTheme(
|
||||
name: string,
|
||||
label: string,
|
||||
primary: PrimaryColors
|
||||
): Theme {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
name: string;
|
||||
label: string;
|
||||
cssVars: {
|
||||
light: { ...baseLightColors, ...primary.light },
|
||||
dark: { ...baseDarkColors, ...primary.dark },
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
}
|
||||
const baseLightColors: Record<string, string> = {
|
||||
background: "oklch(1 0 0)",
|
||||
foreground: "oklch(0.145 0 0)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.145 0 0)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.145 0 0)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.97 0 0)",
|
||||
"muted-foreground": "oklch(0.556 0 0)",
|
||||
accent: "oklch(0.97 0 0)",
|
||||
"accent-foreground": "oklch(0.205 0 0)",
|
||||
destructive: "oklch(0.58 0.22 27)",
|
||||
border: "oklch(0.922 0 0)",
|
||||
input: "oklch(0.922 0 0)",
|
||||
ring: "oklch(0.708 0 0)",
|
||||
};
|
||||
const baseDarkColors: Record<string, string> = {
|
||||
background: "oklch(0.145 0 0)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.205 0 0)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.205 0 0)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.269 0 0)",
|
||||
"muted-foreground": "oklch(0.708 0 0)",
|
||||
accent: "oklch(0.371 0 0)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.556 0 0)",
|
||||
};
|
||||
interface PrimaryColors {
|
||||
light: {
|
||||
primary: string;
|
||||
"primary-foreground": string;
|
||||
};
|
||||
dark: {
|
||||
primary: string;
|
||||
"primary-foreground": string;
|
||||
};
|
||||
}
|
||||
const primaryColors: Record<string, PrimaryColors> = {
|
||||
amber: {
|
||||
light: {
|
||||
primary: "oklch(0.67 0.16 58)",
|
||||
"primary-foreground": "oklch(0.99 0.02 95)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.77 0.16 70)",
|
||||
"primary-foreground": "oklch(0.28 0.07 46)",
|
||||
},
|
||||
},
|
||||
};
|
||||
blue: {
|
||||
light: {
|
||||
primary: "oklch(0.488 0.243 264.376)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.42 0.18 266)",
|
||||
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||
},
|
||||
},
|
||||
cyan: {
|
||||
light: {
|
||||
primary: "oklch(0.61 0.11 222)",
|
||||
"primary-foreground": "oklch(0.98 0.02 201)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.71 0.13 215)",
|
||||
"primary-foreground": "oklch(0.30 0.05 230)",
|
||||
},
|
||||
},
|
||||
emerald: {
|
||||
light: {
|
||||
primary: "oklch(0.60 0.13 163)",
|
||||
"primary-foreground": "oklch(0.98 0.02 166)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.70 0.15 162)",
|
||||
"primary-foreground": "oklch(0.26 0.05 173)",
|
||||
},
|
||||
},
|
||||
fuchsia: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.26 323)",
|
||||
"primary-foreground": "oklch(0.98 0.02 320)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.67 0.26 322)",
|
||||
"primary-foreground": "oklch(0.98 0.02 320)",
|
||||
},
|
||||
},
|
||||
green: {
|
||||
light: {
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.648 0.2 131.684)",
|
||||
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||
},
|
||||
},
|
||||
indigo: {
|
||||
light: {
|
||||
primary: "oklch(0.51 0.23 277)",
|
||||
"primary-foreground": "oklch(0.96 0.02 272)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.59 0.20 277)",
|
||||
"primary-foreground": "oklch(0.96 0.02 272)",
|
||||
},
|
||||
},
|
||||
lime: {
|
||||
light: {
|
||||
primary: "oklch(0.65 0.18 132)",
|
||||
"primary-foreground": "oklch(0.99 0.03 121)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.77 0.20 131)",
|
||||
"primary-foreground": "oklch(0.27 0.07 132)",
|
||||
},
|
||||
},
|
||||
neutral: {
|
||||
light: {
|
||||
primary: "oklch(0.205 0 0)",
|
||||
"primary-foreground": "oklch(0.985 0 0)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.922 0 0)",
|
||||
"primary-foreground": "oklch(0.205 0 0)",
|
||||
},
|
||||
},
|
||||
orange: {
|
||||
light: {
|
||||
primary: "oklch(0.646 0.222 41.116)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.705 0.213 47.604)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
},
|
||||
},
|
||||
pink: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.22 1)",
|
||||
"primary-foreground": "oklch(0.97 0.01 343)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.66 0.21 354)",
|
||||
"primary-foreground": "oklch(0.97 0.01 343)",
|
||||
},
|
||||
},
|
||||
purple: {
|
||||
light: {
|
||||
primary: "oklch(0.56 0.25 302)",
|
||||
"primary-foreground": "oklch(0.98 0.01 308)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.63 0.23 304)",
|
||||
"primary-foreground": "oklch(0.98 0.01 308)",
|
||||
},
|
||||
},
|
||||
red: {
|
||||
light: {
|
||||
primary: "oklch(0.577 0.245 27.325)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.637 0.237 25.331)",
|
||||
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||
},
|
||||
},
|
||||
rose: {
|
||||
light: {
|
||||
primary: "oklch(0.586 0.253 17.585)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.645 0.246 16.439)",
|
||||
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||
},
|
||||
},
|
||||
sky: {
|
||||
light: {
|
||||
primary: "oklch(0.59 0.14 242)",
|
||||
"primary-foreground": "oklch(0.98 0.01 237)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.68 0.15 237)",
|
||||
"primary-foreground": "oklch(0.29 0.06 243)",
|
||||
},
|
||||
},
|
||||
teal: {
|
||||
light: {
|
||||
primary: "oklch(0.60 0.10 185)",
|
||||
"primary-foreground": "oklch(0.98 0.01 181)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.70 0.12 183)",
|
||||
"primary-foreground": "oklch(0.28 0.04 193)",
|
||||
},
|
||||
},
|
||||
violet: {
|
||||
light: {
|
||||
primary: "oklch(0.541 0.281 293.009)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.606 0.25 292.717)",
|
||||
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||
},
|
||||
},
|
||||
yellow: {
|
||||
light: {
|
||||
primary: "oklch(0.852 0.199 91.936)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
},
|
||||
dark: {
|
||||
primary: "oklch(0.795 0.184 86.047)",
|
||||
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||
},
|
||||
},
|
||||
};
|
||||
function createTheme(name: string, label: string, primary: PrimaryColors): Theme {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
cssVars: {
|
||||
light: { ...baseLightColors, ...primary.light },
|
||||
dark: { ...baseDarkColors, ...primary.dark },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const themes: Theme[] = [
|
||||
createTheme("amber", "Amber", primaryColors.amber),
|
||||
createTheme("blue", "Blue", primaryColors.blue),
|
||||
createTheme("cyan", "Cyan", primaryColors.cyan),
|
||||
createTheme("emerald", "Emerald", primaryColors.emerald),
|
||||
createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia),
|
||||
createTheme("green", "Green", primaryColors.green),
|
||||
createTheme("indigo", "Indigo", primaryColors.indigo),
|
||||
createTheme("lime", "Lime", primaryColors.lime),
|
||||
createTheme("neutral", "Neutral", primaryColors.neutral),
|
||||
createTheme("orange", "Orange", primaryColors.orange),
|
||||
createTheme("pink", "Pink", primaryColors.pink),
|
||||
createTheme("purple", "Purple", primaryColors.purple),
|
||||
createTheme("red", "Red", primaryColors.red),
|
||||
createTheme("rose", "Rose", primaryColors.rose),
|
||||
createTheme("sky", "Sky", primaryColors.sky),
|
||||
createTheme("teal", "Teal", primaryColors.teal),
|
||||
createTheme("violet", "Violet", primaryColors.violet),
|
||||
createTheme("yellow", "Yellow", primaryColors.yellow),
|
||||
createTheme("amber", "Amber", primaryColors.amber),
|
||||
createTheme("blue", "Blue", primaryColors.blue),
|
||||
createTheme("cyan", "Cyan", primaryColors.cyan),
|
||||
createTheme("emerald", "Emerald", primaryColors.emerald),
|
||||
createTheme("fuchsia", "Fuchsia", primaryColors.fuchsia),
|
||||
createTheme("green", "Green", primaryColors.green),
|
||||
createTheme("indigo", "Indigo", primaryColors.indigo),
|
||||
createTheme("lime", "Lime", primaryColors.lime),
|
||||
createTheme("neutral", "Neutral", primaryColors.neutral),
|
||||
createTheme("orange", "Orange", primaryColors.orange),
|
||||
createTheme("pink", "Pink", primaryColors.pink),
|
||||
createTheme("purple", "Purple", primaryColors.purple),
|
||||
createTheme("red", "Red", primaryColors.red),
|
||||
createTheme("rose", "Rose", primaryColors.rose),
|
||||
createTheme("sky", "Sky", primaryColors.sky),
|
||||
createTheme("teal", "Teal", primaryColors.teal),
|
||||
createTheme("violet", "Violet", primaryColors.violet),
|
||||
createTheme("yellow", "Yellow", primaryColors.yellow),
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export function applyTheme(themeName: string) {
|
||||
const theme = themes.find((t) => t.name === themeName) || themes[0];
|
||||
const root = document.documentElement;
|
||||
const isDark = root.classList.contains("dark");
|
||||
const vars = isDark ? theme.cssVars.dark : theme.cssVars.light;
|
||||
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
});
|
||||
const theme = themes.find((t) => t.name === themeName) || themes[0];
|
||||
const root = document.documentElement;
|
||||
const isDark = root.classList.contains("dark");
|
||||
const vars = isDark ? theme.cssVars.dark : theme.cssVars.light;
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
playSuccessSound,
|
||||
playErrorSound,
|
||||
playWarningSound,
|
||||
playInfoSound,
|
||||
} from "./audio";
|
||||
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound, } from "./audio";
|
||||
import { logger } from "./logger";
|
||||
import { getSettings } from "./settings";
|
||||
|
||||
const toastStyle = {
|
||||
className: "font-mono lowercase",
|
||||
className: "font-mono lowercase",
|
||||
};
|
||||
|
||||
// Helper to check if SFX is enabled
|
||||
const isSfxEnabled = () => getSettings().sfxEnabled;
|
||||
|
||||
// Wrapper functions for toast with sound effects
|
||||
export const toastWithSound = {
|
||||
success: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.success(msg);
|
||||
if (isSfxEnabled()) playSuccessSound();
|
||||
return toast.success(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
error: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.error(msg);
|
||||
if (isSfxEnabled()) playErrorSound();
|
||||
return toast.error(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
warning: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.warning(msg);
|
||||
if (isSfxEnabled()) playWarningSound();
|
||||
return toast.warning(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
info: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
if (isSfxEnabled()) playInfoSound();
|
||||
return toast.info(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
// Default toast without specific type
|
||||
message: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
if (isSfxEnabled()) playInfoSound();
|
||||
return toast(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
success: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.success(msg);
|
||||
if (isSfxEnabled())
|
||||
playSuccessSound();
|
||||
return toast.success(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
error: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.error(msg);
|
||||
if (isSfxEnabled())
|
||||
playErrorSound();
|
||||
return toast.error(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
warning: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.warning(msg);
|
||||
if (isSfxEnabled())
|
||||
playWarningSound();
|
||||
return toast.warning(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
info: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
if (isSfxEnabled())
|
||||
playInfoSound();
|
||||
return toast.info(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
message: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
if (isSfxEnabled())
|
||||
playInfoSound();
|
||||
return toast(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
};
|
||||
|
||||
+42
-54
@@ -1,60 +1,48 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime";
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
|
||||
export function sanitizePath(input: string, os: string): string {
|
||||
let sanitized = input.trim(); // it will trim whitespace
|
||||
if (os === "Windows") {
|
||||
return input.replace(/[<>:"/\\|?*]/g, "_");
|
||||
}
|
||||
|
||||
// unix-based OS
|
||||
return input.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
export function joinPath(os: string, ...parts: string[]): string {
|
||||
const sep = os === "Windows" ? "\\" : "/";
|
||||
|
||||
const filtered = parts.filter(Boolean);
|
||||
if (filtered.length === 0) return "";
|
||||
|
||||
const joined = filtered
|
||||
.map((p, i) => {
|
||||
// For first part, only remove trailing slashes (preserve leading slash for absolute paths)
|
||||
if (i === 0) {
|
||||
return p.replace(/[/\\]+$/g, "");
|
||||
}
|
||||
// For other parts, remove both leading and trailing slashes
|
||||
return p.replace(/^[/\\]+|[/\\]+$/g, "");
|
||||
})
|
||||
.filter(Boolean) // Remove empty strings after trimming
|
||||
.join(sep);
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
export function buildOutputPath(settings: Settings, folder?: string) {
|
||||
const os = settings.operatingSystem;
|
||||
|
||||
const base = settings.downloadPath || "";
|
||||
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
||||
|
||||
return sanitized ? joinPath(os, base, sanitized) : base;
|
||||
}
|
||||
|
||||
export function openExternal(url: string) {
|
||||
if (!url) return;
|
||||
try {
|
||||
BrowserOpenURL(url);
|
||||
} catch (error) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
let sanitized = input.trim();
|
||||
if (os === "Windows") {
|
||||
return sanitized.replace(/[<>:"/\\|?*]/g, "_");
|
||||
}
|
||||
return sanitized.replace(/\//g, "_");
|
||||
}
|
||||
export function joinPath(os: string, ...parts: string[]): string {
|
||||
const sep = os === "Windows" ? "\\" : "/";
|
||||
const filtered = parts.filter(Boolean);
|
||||
if (filtered.length === 0)
|
||||
return "";
|
||||
const joined = filtered
|
||||
.map((p, i) => {
|
||||
if (i === 0) {
|
||||
return p.replace(/[/\\]+$/g, "");
|
||||
}
|
||||
return p.replace(/^[/\\]+|[/\\]+$/g, "");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(sep);
|
||||
return joined;
|
||||
}
|
||||
export function buildOutputPath(settings: Settings, folder?: string) {
|
||||
const os = settings.operatingSystem;
|
||||
const base = settings.downloadPath || "";
|
||||
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
||||
return sanitized ? joinPath(os, base, sanitized) : base;
|
||||
}
|
||||
export function openExternal(url: string) {
|
||||
if (!url)
|
||||
return;
|
||||
try {
|
||||
BrowserOpenURL(url);
|
||||
}
|
||||
catch (error) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode>
|
||||
<App />
|
||||
<Toaster position="bottom-left" duration={1000} />
|
||||
</StrictMode>
|
||||
);
|
||||
<Toaster position="bottom-left" duration={1000}/>
|
||||
</StrictMode>);
|
||||
|
||||
+236
-212
@@ -1,249 +1,273 @@
|
||||
export interface ArtistSimple {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}
|
||||
|
||||
export interface TrackMetadata {
|
||||
artists: string;
|
||||
name: string;
|
||||
album_name: string;
|
||||
album_artist?: string;
|
||||
duration_ms: number;
|
||||
images: string;
|
||||
release_date: string;
|
||||
track_number: number;
|
||||
total_tracks?: number; // Total tracks in album
|
||||
disc_number?: number;
|
||||
external_urls: string;
|
||||
isrc: string;
|
||||
album_type?: string;
|
||||
spotify_id?: string;
|
||||
album_id?: string;
|
||||
album_url?: string;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
artists_data?: ArtistSimple[];
|
||||
}
|
||||
|
||||
export interface TrackResponse {
|
||||
track: TrackMetadata;
|
||||
}
|
||||
|
||||
export interface AlbumInfo {
|
||||
total_tracks: number;
|
||||
name: string;
|
||||
release_date: string;
|
||||
artists: string;
|
||||
images: string;
|
||||
batch?: string;
|
||||
}
|
||||
|
||||
export interface AlbumResponse {
|
||||
album_info: AlbumInfo;
|
||||
track_list: TrackMetadata[];
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
tracks: {
|
||||
total: number;
|
||||
};
|
||||
followers: {
|
||||
total: number;
|
||||
};
|
||||
owner: {
|
||||
display_name: string;
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}
|
||||
export interface TrackMetadata {
|
||||
artists: string;
|
||||
name: string;
|
||||
album_name: string;
|
||||
album_artist?: string;
|
||||
duration_ms: number;
|
||||
images: string;
|
||||
};
|
||||
batch?: string;
|
||||
release_date: string;
|
||||
track_number: number;
|
||||
total_tracks?: number;
|
||||
total_discs?: number;
|
||||
disc_number?: number;
|
||||
external_urls: string;
|
||||
isrc: string;
|
||||
album_type?: string;
|
||||
spotify_id?: string;
|
||||
album_id?: string;
|
||||
album_url?: string;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
artists_data?: ArtistSimple[];
|
||||
copyright?: string;
|
||||
publisher?: string;
|
||||
plays?: string;
|
||||
status?: string;
|
||||
}
|
||||
export interface TrackResponse {
|
||||
track: TrackMetadata;
|
||||
}
|
||||
export interface AlbumInfo {
|
||||
total_tracks: number;
|
||||
name: string;
|
||||
release_date: string;
|
||||
artists: string;
|
||||
images: string;
|
||||
batch?: string;
|
||||
}
|
||||
export interface AlbumResponse {
|
||||
album_info: AlbumInfo;
|
||||
track_list: TrackMetadata[];
|
||||
}
|
||||
export interface PlaylistInfo {
|
||||
tracks: {
|
||||
total: number;
|
||||
};
|
||||
followers: {
|
||||
total: number;
|
||||
};
|
||||
owner: {
|
||||
display_name: string;
|
||||
name: string;
|
||||
images: string;
|
||||
};
|
||||
cover?: string;
|
||||
description?: string;
|
||||
batch?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistResponse {
|
||||
playlist_info: PlaylistInfo;
|
||||
track_list: TrackMetadata[];
|
||||
playlist_info: PlaylistInfo;
|
||||
track_list: TrackMetadata[];
|
||||
}
|
||||
|
||||
export interface ArtistInfo {
|
||||
name: string;
|
||||
followers: number;
|
||||
genres: string[];
|
||||
images: string;
|
||||
external_urls: string;
|
||||
discography_type: string;
|
||||
total_albums: number;
|
||||
batch?: string;
|
||||
}
|
||||
|
||||
export interface DiscographyAlbum {
|
||||
id: string;
|
||||
name: string;
|
||||
album_type: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
artists: string;
|
||||
images: string;
|
||||
external_urls: string;
|
||||
}
|
||||
|
||||
export interface ArtistDiscographyResponse {
|
||||
artist_info: ArtistInfo;
|
||||
album_list: DiscographyAlbum[];
|
||||
track_list: TrackMetadata[];
|
||||
}
|
||||
|
||||
export interface ArtistResponse {
|
||||
artist: {
|
||||
name: string;
|
||||
followers: number;
|
||||
genres: string[];
|
||||
images: string;
|
||||
header?: string;
|
||||
gallery?: string[];
|
||||
external_urls: string;
|
||||
popularity: number;
|
||||
};
|
||||
discography_type: string;
|
||||
total_albums: number;
|
||||
biography?: string;
|
||||
verified?: boolean;
|
||||
listeners?: number;
|
||||
rank?: number;
|
||||
batch?: string;
|
||||
}
|
||||
|
||||
export type SpotifyMetadataResponse =
|
||||
| TrackResponse
|
||||
| AlbumResponse
|
||||
| PlaylistResponse
|
||||
| ArtistDiscographyResponse
|
||||
| ArtistResponse;
|
||||
|
||||
export interface DiscographyAlbum {
|
||||
id: string;
|
||||
name: string;
|
||||
album_type: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
artists: string;
|
||||
images: string;
|
||||
external_urls: string;
|
||||
}
|
||||
export interface ArtistDiscographyResponse {
|
||||
artist_info: ArtistInfo;
|
||||
album_list: DiscographyAlbum[];
|
||||
track_list: TrackMetadata[];
|
||||
}
|
||||
export interface ArtistResponse {
|
||||
artist: {
|
||||
name: string;
|
||||
followers: number;
|
||||
genres: string[];
|
||||
images: string;
|
||||
external_urls: string;
|
||||
popularity: number;
|
||||
};
|
||||
}
|
||||
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
|
||||
export interface DownloadRequest {
|
||||
isrc: string;
|
||||
service: "tidal" | "qobuz" | "amazon";
|
||||
query?: string;
|
||||
track_name?: string;
|
||||
artist_name?: string;
|
||||
album_name?: string;
|
||||
album_artist?: string;
|
||||
release_date?: string;
|
||||
cover_url?: string; // Spotify cover URL for embedding
|
||||
api_url?: string;
|
||||
output_dir?: string;
|
||||
audio_format?: string;
|
||||
folder_name?: string;
|
||||
filename_format?: string;
|
||||
track_number?: boolean;
|
||||
position?: number;
|
||||
use_album_track_number?: boolean;
|
||||
spotify_id?: string;
|
||||
embed_lyrics?: boolean; // Whether to embed lyrics into the audio file
|
||||
embed_max_quality_cover?: boolean; // Whether to embed max quality cover art
|
||||
service_url?: string;
|
||||
duration?: number; // Track duration in seconds for better matching
|
||||
item_id?: string; // Optional queue item ID for multi-service fallback tracking
|
||||
spotify_track_number?: number; // Track number from Spotify album
|
||||
spotify_disc_number?: number; // Disc number from Spotify album
|
||||
spotify_total_tracks?: number; // Total tracks in album from Spotify
|
||||
isrc: string;
|
||||
service: "tidal" | "qobuz" | "amazon";
|
||||
query?: string;
|
||||
track_name?: string;
|
||||
artist_name?: string;
|
||||
album_name?: string;
|
||||
album_artist?: string;
|
||||
release_date?: string;
|
||||
cover_url?: string;
|
||||
api_url?: string;
|
||||
output_dir?: string;
|
||||
audio_format?: string;
|
||||
folder_name?: string;
|
||||
filename_format?: string;
|
||||
track_number?: boolean;
|
||||
position?: number;
|
||||
use_album_track_number?: boolean;
|
||||
spotify_id?: string;
|
||||
embed_lyrics?: boolean;
|
||||
embed_max_quality_cover?: boolean;
|
||||
service_url?: string;
|
||||
duration?: number;
|
||||
item_id?: string;
|
||||
spotify_track_number?: number;
|
||||
spotify_disc_number?: number;
|
||||
spotify_total_tracks?: number;
|
||||
spotify_total_discs?: number;
|
||||
copyright?: string;
|
||||
publisher?: string;
|
||||
spotify_url?: string;
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
item_id?: string; // Queue item ID for tracking
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
item_id?: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
time: string;
|
||||
status: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface TimeSlice {
|
||||
time: number;
|
||||
magnitudes: number[];
|
||||
time: number;
|
||||
magnitudes: number[];
|
||||
}
|
||||
|
||||
export interface SpectrumData {
|
||||
time_slices: TimeSlice[];
|
||||
sample_rate: number;
|
||||
freq_bins: number;
|
||||
duration: number;
|
||||
max_freq: number;
|
||||
time_slices: TimeSlice[];
|
||||
sample_rate: number;
|
||||
freq_bins: number;
|
||||
duration: number;
|
||||
max_freq: number;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
bits_per_sample: number;
|
||||
total_samples: number;
|
||||
duration: number;
|
||||
bit_depth: string;
|
||||
dynamic_range: number;
|
||||
peak_amplitude: number;
|
||||
rms_level: number;
|
||||
spectrum?: SpectrumData;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
bits_per_sample: number;
|
||||
total_samples: number;
|
||||
duration: number;
|
||||
bit_depth: string;
|
||||
dynamic_range: number;
|
||||
peak_amplitude: number;
|
||||
rms_level: number;
|
||||
spectrum?: SpectrumData;
|
||||
}
|
||||
|
||||
export interface LyricsDownloadRequest {
|
||||
spotify_id: string;
|
||||
track_name: string;
|
||||
artist_name: string;
|
||||
album_name?: string;
|
||||
album_artist?: string;
|
||||
release_date?: string;
|
||||
output_dir?: string;
|
||||
filename_format?: string;
|
||||
track_number?: boolean;
|
||||
position?: number;
|
||||
use_album_track_number?: boolean;
|
||||
disc_number?: number;
|
||||
spotify_id: string;
|
||||
track_name: string;
|
||||
artist_name: string;
|
||||
album_name?: string;
|
||||
album_artist?: string;
|
||||
release_date?: string;
|
||||
output_dir?: string;
|
||||
filename_format?: string;
|
||||
track_number?: boolean;
|
||||
position?: number;
|
||||
use_album_track_number?: boolean;
|
||||
disc_number?: number;
|
||||
}
|
||||
|
||||
export interface LyricsDownloadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
}
|
||||
|
||||
export interface TrackAvailability {
|
||||
spotify_id: string;
|
||||
tidal: boolean;
|
||||
amazon: boolean;
|
||||
qobuz: boolean;
|
||||
tidal_url?: string;
|
||||
amazon_url?: string;
|
||||
qobuz_url?: string;
|
||||
spotify_id: string;
|
||||
tidal: boolean;
|
||||
amazon: boolean;
|
||||
qobuz: boolean;
|
||||
tidal_url?: string;
|
||||
amazon_url?: string;
|
||||
qobuz_url?: string;
|
||||
}
|
||||
|
||||
export interface CoverDownloadRequest {
|
||||
cover_url: string;
|
||||
track_name: string;
|
||||
artist_name: string;
|
||||
album_name?: string;
|
||||
album_artist?: string;
|
||||
release_date?: string;
|
||||
output_dir?: string;
|
||||
filename_format?: string;
|
||||
track_number?: boolean;
|
||||
position?: number;
|
||||
disc_number?: number;
|
||||
cover_url: string;
|
||||
track_name: string;
|
||||
artist_name: string;
|
||||
album_name?: string;
|
||||
album_artist?: string;
|
||||
release_date?: string;
|
||||
output_dir?: string;
|
||||
filename_format?: string;
|
||||
track_number?: boolean;
|
||||
position?: number;
|
||||
disc_number?: number;
|
||||
}
|
||||
|
||||
export interface CoverDownloadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
}
|
||||
export interface HeaderDownloadRequest {
|
||||
header_url: string;
|
||||
artist_name: string;
|
||||
output_dir?: string;
|
||||
}
|
||||
export interface HeaderDownloadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
}
|
||||
export interface GalleryImageDownloadRequest {
|
||||
image_url: string;
|
||||
artist_name: string;
|
||||
image_index: number;
|
||||
output_dir?: string;
|
||||
}
|
||||
export interface GalleryImageDownloadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
}
|
||||
export interface AvatarDownloadRequest {
|
||||
avatar_url: string;
|
||||
artist_name: string;
|
||||
output_dir?: string;
|
||||
}
|
||||
export interface AvatarDownloadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface AudioMetadata {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
album_artist: string;
|
||||
track_number: number;
|
||||
disc_number: number;
|
||||
year: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
album_artist: string;
|
||||
track_number: number;
|
||||
disc_number: number;
|
||||
year: string;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,9 @@ import (
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
// Create an instance of the app structure
|
||||
|
||||
app := NewApp()
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
Title: "SpotiFLAC",
|
||||
Width: 1024,
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "7.0"
|
||||
"productVersion": "7.0.1",
|
||||
"copyright": "© 2026 afkarxyz",
|
||||
"comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required."
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
"assetdir": "./frontend/dist",
|
||||
|
||||
Reference in New Issue
Block a user