v7.1.6
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -55,4 +56,4 @@
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
867c45db7982e126a7249d80210f23be
|
||||
8864b4f7b7971b624d1ba25030f2db4e
|
||||
Generated
+3
@@ -32,6 +32,9 @@ importers:
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: ^1.3.6
|
||||
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
||||
|
||||
@@ -162,7 +162,7 @@ function App() {
|
||||
if (savedSettings) {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
applyFont(savedSettings.fontFamily);
|
||||
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
@@ -170,7 +170,7 @@ function App() {
|
||||
const settings = await loadSettings();
|
||||
applyThemeMode(settings.themeMode);
|
||||
applyTheme(settings.theme);
|
||||
applyFont(settings.fontFamily);
|
||||
applyFont(settings.fontFamily, settings.customFonts);
|
||||
if (!settings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
await saveSettings(settingsWithDefaults);
|
||||
@@ -446,7 +446,7 @@ function App() {
|
||||
}
|
||||
if ("album_info" in metadata.metadata) {
|
||||
const { album_info, track_list } = metadata.metadata;
|
||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
@@ -464,7 +464,7 @@ function App() {
|
||||
const { playlist_info, track_list } = metadata.metadata;
|
||||
const settings = getSettings();
|
||||
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
|
||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
@@ -480,7 +480,7 @@ function App() {
|
||||
}
|
||||
if ("artist_info" in metadata.metadata) {
|
||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
@@ -512,7 +512,7 @@ function App() {
|
||||
const savedSettings = getSettings();
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
applyFont(savedSettings.fontFamily);
|
||||
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||
if (pendingPageChange) {
|
||||
setCurrentPage(pendingPageChange);
|
||||
setPendingPageChange(null);
|
||||
@@ -551,7 +551,7 @@ function App() {
|
||||
|
||||
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<DialogContent className="sm:max-w-106.25 p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
<X className="h-4 w-4"/>
|
||||
@@ -624,7 +624,7 @@ function App() {
|
||||
|
||||
|
||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
||||
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -671,7 +671,7 @@ function App() {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
||||
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
|
||||
<DialogContent className="max-w-112.5 [&>button]:hidden p-6 gap-5">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle className="text-lg font-bold tracking-tight">
|
||||
FFmpeg Required
|
||||
|
||||
@@ -249,7 +249,7 @@ export function AboutPage() {
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
||||
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
This project released as a token of appreciation for those who have supported SpotiFLAC on Ko-fi. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
@@ -318,7 +318,7 @@ export function AboutPage() {
|
||||
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
||||
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
|
||||
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
|
||||
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/>
|
||||
<span className={`${projectBodyClass} text-muted-foreground`}>
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface AlbumInfoProps {
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
downloadRemainingCount: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
@@ -77,7 +78,7 @@ interface AlbumInfoProps {
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||
const settings = getSettings();
|
||||
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
||||
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
||||
@@ -270,7 +271,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -48,6 +48,7 @@ interface ArtistInfoProps {
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
downloadRemainingCount: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
@@ -95,7 +96,7 @@ interface ArtistInfoProps {
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
onBack?: () => 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, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||
@@ -325,7 +326,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
{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 inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/>
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
|
||||
<XCircle className="h-5 w-5"/>
|
||||
@@ -563,7 +564,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
Filter Albums
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Albums</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -634,7 +635,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} 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>)}
|
||||
|
||||
@@ -3,14 +3,17 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { StopCircle } from "lucide-react";
|
||||
interface DownloadProgressProps {
|
||||
progress: number;
|
||||
remainingCount?: number;
|
||||
currentTrack: {
|
||||
name: string;
|
||||
artists: string;
|
||||
} | null;
|
||||
onStop: () => void;
|
||||
}
|
||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
const safeRemainingCount = Math.max(0, remainingCount);
|
||||
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
|
||||
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"/>
|
||||
@@ -20,7 +23,7 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{clampedProgress}% -{" "}
|
||||
{clampedProgress}% • {remainingLabel} -{" "}
|
||||
{currentTrack
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||
import { getPreviewVolume } from "@/lib/preview";
|
||||
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
@@ -21,6 +22,37 @@ const formatDate = (timestamp: number) => {
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
const getHistoryFormatLabel = (item: DownloadHistoryItem) => {
|
||||
const normalizedPath = (item.path || "").trim().toLowerCase();
|
||||
if (normalizedPath.endsWith(".flac"))
|
||||
return "FLAC";
|
||||
if (normalizedPath.endsWith(".mp3"))
|
||||
return "MP3";
|
||||
if (normalizedPath.endsWith(".m4a"))
|
||||
return "M4A";
|
||||
const normalizedFormat = (item.format || "").trim().toLowerCase();
|
||||
switch (normalizedFormat) {
|
||||
case "hi_res":
|
||||
case "hi_res_lossless":
|
||||
case "lossless":
|
||||
case "flac":
|
||||
case "6":
|
||||
case "7":
|
||||
case "27":
|
||||
return "FLAC";
|
||||
case "alac":
|
||||
case "apple":
|
||||
case "atmos":
|
||||
case "m4a":
|
||||
case "m4a-aac":
|
||||
case "m4a-alac":
|
||||
return "M4A";
|
||||
case "mp3":
|
||||
return "MP3";
|
||||
default:
|
||||
return (item.format || "-").toUpperCase();
|
||||
}
|
||||
};
|
||||
interface DownloadHistoryItem {
|
||||
id: string;
|
||||
spotify_id: string;
|
||||
@@ -57,7 +89,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
||||
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
||||
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const playbackRef = useRef<PreviewPlayback | null>(null);
|
||||
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
||||
@@ -122,9 +154,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
}, [activeTab]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
playbackRef.current?.destroy();
|
||||
playbackRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
@@ -180,20 +211,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
}, [fetchSearchQuery, activeFetchTab]);
|
||||
const handlePreview = async (id: string, spotifyId: string) => {
|
||||
if (playingPreviewId === id) {
|
||||
audioRef.current?.pause();
|
||||
playbackRef.current?.destroy();
|
||||
playbackRef.current = null;
|
||||
setPlayingPreviewId(null);
|
||||
return;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
if (playbackRef.current) {
|
||||
playbackRef.current.destroy();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
try {
|
||||
const url = await GetPreviewURL(spotifyId);
|
||||
if (url) {
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||
audio.onended = () => setPlayingPreviewId(null);
|
||||
const playback = await createPreviewPlayback(url, getPreviewVolume());
|
||||
const audio = playback.audio;
|
||||
playbackRef.current = playback;
|
||||
audio.onended = () => {
|
||||
setPlayingPreviewId(null);
|
||||
if (playbackRef.current?.audio === audio) {
|
||||
playbackRef.current.destroy();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
};
|
||||
audio.onerror = () => {
|
||||
setPlayingPreviewId(null);
|
||||
if (playbackRef.current?.audio === audio) {
|
||||
playbackRef.current.destroy();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
};
|
||||
audio.play();
|
||||
setPlayingPreviewId(id);
|
||||
}
|
||||
@@ -271,7 +317,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||
</div>
|
||||
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<SelectTrigger className="w-45 h-9">
|
||||
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
||||
<SelectValue placeholder="Sort by"/>
|
||||
</SelectTrigger>
|
||||
@@ -329,10 +375,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
<div className="truncate">{item.album}</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="text-xs font-bold text-foreground">
|
||||
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
||||
{getHistoryFormatLabel(item)}
|
||||
</span>
|
||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ interface PlaylistInfoProps {
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
downloadRemainingCount: number;
|
||||
currentDownloadInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
@@ -88,7 +89,7 @@ interface PlaylistInfoProps {
|
||||
onTrackClick: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||
const settings = getSettings();
|
||||
const playlistName = playlistInfo.owner.name;
|
||||
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
|
||||
@@ -235,7 +236,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
|
||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
||||
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { ApiStatusTab } from "./ApiStatusTab";
|
||||
import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons";
|
||||
interface SettingsPageProps {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
}
|
||||
type CustomTidalApiStatus = "idle" | "checking" | "online" | "offline";
|
||||
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 [showAddFontDialog, setShowAddFontDialog] = useState(false);
|
||||
const [showCustomTidalApiDialog, setShowCustomTidalApiDialog] = useState(false);
|
||||
const [addFontUrl, setAddFontUrl] = useState("");
|
||||
const [customTidalApiStatus, setCustomTidalApiStatus] = useState<CustomTidalApiStatus>("idle");
|
||||
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
|
||||
const fontOptions = getFontOptions(tempSettings.customFonts);
|
||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||
const resetToSaved = useCallback(() => {
|
||||
const freshSavedSettings = getSettings();
|
||||
@@ -55,14 +64,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
useEffect(() => {
|
||||
applyThemeMode(tempSettings.themeMode);
|
||||
applyTheme(tempSettings.theme);
|
||||
applyFont(tempSettings.fontFamily);
|
||||
applyFont(tempSettings.fontFamily, tempSettings.customFonts);
|
||||
setTimeout(() => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
}, 0);
|
||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily, tempSettings.customFonts]);
|
||||
useEffect(() => {
|
||||
if (showAddFontDialog && parsedAddFont) {
|
||||
loadGoogleFontUrl(parsedAddFont.url, "spotiflac-add-font-preview");
|
||||
}
|
||||
}, [showAddFontDialog, parsedAddFont]);
|
||||
useEffect(() => {
|
||||
const loadDefaults = async () => {
|
||||
if (!savedSettings.downloadPath) {
|
||||
const currentSettings = getSettings();
|
||||
if (!currentSettings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
setSavedSettings(settingsWithDefaults);
|
||||
setTempSettings(settingsWithDefaults);
|
||||
@@ -71,6 +86,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
};
|
||||
loadDefaults();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const syncCustomFonts = async () => {
|
||||
const customFonts = await loadCustomFonts();
|
||||
setSavedSettings((prev) => ({ ...prev, customFonts }));
|
||||
setTempSettings((prev) => ({ ...prev, customFonts }));
|
||||
};
|
||||
void syncCustomFonts();
|
||||
}, []);
|
||||
const handleSave = async () => {
|
||||
await saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
@@ -83,7 +106,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
setSavedSettings(defaultSettings);
|
||||
applyThemeMode(defaultSettings.themeMode);
|
||||
applyTheme(defaultSettings.theme);
|
||||
applyFont(defaultSettings.fontFamily);
|
||||
applyFont(defaultSettings.fontFamily, defaultSettings.customFonts);
|
||||
setShowResetConfirm(false);
|
||||
toast.success("Settings reset to default");
|
||||
};
|
||||
@@ -99,18 +122,100 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
toast.error(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
const closeAddFontDialog = () => {
|
||||
setShowAddFontDialog(false);
|
||||
setAddFontUrl("");
|
||||
};
|
||||
const handleAddFont = async () => {
|
||||
if (!parsedAddFont) {
|
||||
toast.error("Enter a valid Google Fonts URL");
|
||||
return;
|
||||
}
|
||||
const existingFonts = tempSettings.customFonts || [];
|
||||
const existingIndex = existingFonts.findIndex((font) => font.value === parsedAddFont.value || font.url === parsedAddFont.url);
|
||||
const customFonts = existingIndex >= 0
|
||||
? existingFonts.map((font, index) => index === existingIndex ? parsedAddFont : font)
|
||||
: [...existingFonts, parsedAddFont];
|
||||
const savedCustomFonts = await saveCustomFonts(customFonts);
|
||||
setSavedSettings((prev) => ({ ...prev, customFonts: savedCustomFonts }));
|
||||
setTempSettings((prev) => ({
|
||||
...prev,
|
||||
customFonts: savedCustomFonts,
|
||||
fontFamily: parsedAddFont.value,
|
||||
}));
|
||||
closeAddFontDialog();
|
||||
toast.success(`${parsedAddFont.label} added`);
|
||||
};
|
||||
const handleDeleteCustomFont = async (fontValue: CustomFontFamily) => {
|
||||
const customFonts = (tempSettings.customFonts || []).filter((font) => font.value !== fontValue);
|
||||
const savedCustomFonts = await saveCustomFonts(customFonts);
|
||||
const shouldResetSavedFont = savedSettings.fontFamily === fontValue;
|
||||
const shouldResetTempFont = tempSettings.fontFamily === fontValue;
|
||||
const nextSavedSettings: SettingsType = {
|
||||
...savedSettings,
|
||||
customFonts: savedCustomFonts,
|
||||
fontFamily: shouldResetSavedFont ? "google-sans" : savedSettings.fontFamily,
|
||||
};
|
||||
setSavedSettings(nextSavedSettings);
|
||||
setTempSettings((prev) => ({
|
||||
...prev,
|
||||
customFonts: savedCustomFonts,
|
||||
fontFamily: shouldResetTempFont ? "google-sans" : prev.fontFamily,
|
||||
}));
|
||||
if (shouldResetSavedFont) {
|
||||
await saveSettings(nextSavedSettings);
|
||||
}
|
||||
toast.success("Font deleted");
|
||||
};
|
||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||
};
|
||||
const handleTidalVariantChange = (value: "tidal" | "alt") => {
|
||||
setTempSettings((prev) => ({ ...prev, tidalVariant: value }));
|
||||
};
|
||||
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||
};
|
||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||
};
|
||||
const persistCustomTidalApi = useCallback(async (nextValue: string) => {
|
||||
const normalizedValue = nextValue.trim().replace(/\/+$/g, "");
|
||||
const persistedSettings = getSettings();
|
||||
const nextSavedSettings: SettingsType = {
|
||||
...persistedSettings,
|
||||
customTidalApi: normalizedValue,
|
||||
};
|
||||
await saveSettings(nextSavedSettings);
|
||||
setSavedSettings((prev) => ({
|
||||
...prev,
|
||||
customTidalApi: normalizedValue,
|
||||
}));
|
||||
setTempSettings((prev) => ({
|
||||
...prev,
|
||||
customTidalApi: normalizedValue,
|
||||
}));
|
||||
}, []);
|
||||
const handleCheckCustomTidalApi = async () => {
|
||||
const normalizedCustomTidalApi = (tempSettings.customTidalApi || "").trim().replace(/\/+$/g, "");
|
||||
if (!normalizedCustomTidalApi.startsWith("https://")) {
|
||||
toast.error("Enter a valid HTTPS HiFi API URL");
|
||||
return;
|
||||
}
|
||||
setCustomTidalApiStatus("checking");
|
||||
try {
|
||||
const isOnline = await CheckCustomTidalAPI(normalizedCustomTidalApi);
|
||||
setCustomTidalApiStatus(isOnline ? "online" : "offline");
|
||||
if (isOnline) {
|
||||
toast.success("HiFi API instance is online");
|
||||
}
|
||||
else {
|
||||
toast.error("HiFi API instance is offline");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to check custom Tidal API:", error);
|
||||
setCustomTidalApiStatus("offline");
|
||||
toast.error(`Failed to check HiFi API instance: ${error}`);
|
||||
}
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
||||
return (<div className="space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
@@ -207,18 +312,39 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font">Font</Label>
|
||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||
<SelectTrigger id="font">
|
||||
<SelectValue placeholder="Select a font"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||
<span style={{ fontFamily: font.fontFamily }}>
|
||||
{font.label}
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||
<SelectTrigger id="font" className="max-w-full min-w-40">
|
||||
<SelectValue placeholder="Select a font"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fontOptions.map((font) => {
|
||||
const isCustomFont = font.value.startsWith("custom-");
|
||||
return (<SelectItem key={font.value} value={font.value} indicatorPosition="inline" trailingAction={isCustomFont ? (<Button type="button" variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-muted-foreground hover:bg-transparent hover:text-destructive" aria-label={`Delete ${font.label}`} onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}} onPointerUp={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}} onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDeleteCustomFont(font.value as CustomFontFamily);
|
||||
}}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-inherit"/>
|
||||
</Button>) : undefined}>
|
||||
<span style={{ fontFamily: font.fontFamily }}>
|
||||
{font.label}
|
||||
</span>
|
||||
</SelectItem>);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="outline" onClick={() => setShowAddFontDialog(true)} className="shrink-0 gap-1.5">
|
||||
<Plus className="h-4 w-4"/>
|
||||
Add Font
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
@@ -240,7 +366,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
...prev,
|
||||
linkResolver: value,
|
||||
}))}>
|
||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
|
||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
|
||||
<SelectValue placeholder="Select a link resolver"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -273,8 +399,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
downloader: value,
|
||||
}))}>
|
||||
@@ -306,11 +432,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</Select>
|
||||
|
||||
{tempSettings.downloader === "auto" && (<>
|
||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
|
||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
autoOrder: value,
|
||||
}))}>
|
||||
<SelectTrigger className="h-9 w-fit min-w-[140px]">
|
||||
<SelectTrigger className="h-9 w-fit min-w-35">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -427,9 +553,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</Select>
|
||||
</>)}
|
||||
|
||||
{tempSettings.downloader === "tidal" && (tempSettings.tidalVariant === "alt" ? (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
||||
16-bit/44.1kHz
|
||||
</div>) : (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -439,7 +563,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
24-bit/48kHz
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>))}
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
@@ -457,27 +581,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
|
||||
</div>
|
||||
|
||||
{(tempSettings.downloader === "tidal" || tempSettings.downloader === "auto") && (<div className="space-y-2 pt-2">
|
||||
<Label htmlFor="tidal-variant">Tidal Variant</Label>
|
||||
<Select value={tempSettings.tidalVariant || "tidal"} onValueChange={handleTidalVariantChange}>
|
||||
<SelectTrigger id="tidal-variant" className="h-9 w-fit min-w-[160px]">
|
||||
<SelectValue placeholder="Select Tidal variant"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tidal">Tidal</SelectItem>
|
||||
<SelectItem value="alt">Tidal Alt.</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>)}
|
||||
|
||||
{((tempSettings.downloader === "tidal" &&
|
||||
tempSettings.tidalVariant !== "alt" &&
|
||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||
(tempSettings.downloader === "qobuz" &&
|
||||
tempSettings.qobuzQuality === "27") ||
|
||||
(tempSettings.downloader === "auto" &&
|
||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
allowFallback: checked,
|
||||
@@ -485,22 +594,25 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
|
||||
Allow Quality Fallback (16-bit)
|
||||
</Label>
|
||||
</div>)}
|
||||
|
||||
{(tempSettings.downloader === "auto" || tempSettings.downloader === "tidal") && (<div className="space-y-2 pt-2">
|
||||
<Label>Custom Instance</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
|
||||
<TidalIcon />
|
||||
Configure
|
||||
</Button>
|
||||
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
|
||||
{tempSettings.customTidalApi}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6"/>
|
||||
<div className="border-t pt-2"/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedLyrics: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||
Embed Lyrics
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -528,6 +640,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
Use Single Genre
|
||||
</Label>
|
||||
</div>)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedLyrics: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||
Embed Lyrics
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
@@ -645,7 +766,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
|
||||
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
existingFileCheckMode: value,
|
||||
}))}>
|
||||
<SelectTrigger id="existing-file-check-mode">
|
||||
<SelectValue placeholder="Select existing file check mode"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="filename">Filename</SelectItem>
|
||||
<SelectItem value="isrc">ISRC</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
@@ -719,20 +857,119 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
.flac
|
||||
</span>
|
||||
</p>)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "api" && (<ApiStatusTab />)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
|
||||
<DialogContent className="sm:max-w-115 [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle>Add Font</DialogTitle>
|
||||
<button type="button" onClick={() => openExternal("https://fonts.google.com")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||
Open Google Fonts
|
||||
<ExternalLink className="h-3 w-3"/>
|
||||
</button>
|
||||
</div>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="google-font-url">Google Font URL</Label>
|
||||
<Input id="google-font-url" value={addFontUrl} onChange={(event) => setAddFontUrl(event.target.value)} onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && parsedAddFont) {
|
||||
void handleAddFont();
|
||||
}
|
||||
}} placeholder="https://fonts.google.com/specimen/Ubuntu" autoFocus/>
|
||||
{addFontUrl.trim() && !parsedAddFont && (<p className="text-xs text-destructive">
|
||||
Enter a valid Google Fonts URL.
|
||||
</p>)}
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 p-4">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Preview
|
||||
</p>
|
||||
<p className="text-2xl font-semibold leading-tight" style={{ fontFamily: parsedAddFont?.fontFamily }}>
|
||||
Aa The quick brown fox
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground" style={{ fontFamily: parsedAddFont?.fontFamily }}>
|
||||
Kendrick Lamar - All The Stars
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeAddFontDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void handleAddFont()} disabled={!parsedAddFont}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showCustomTidalApiDialog} onOpenChange={setShowCustomTidalApiDialog}>
|
||||
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle>Custom Instance</DialogTitle>
|
||||
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||
How to create your own instance
|
||||
<ExternalLink className="h-3 w-3"/>
|
||||
</button>
|
||||
</div>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-tidal-api">Instance URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="custom-tidal-api" type="url" value={tempSettings.customTidalApi || ""} onChange={(e) => {
|
||||
const nextValue = e.target.value.replace(/\/+$/g, "");
|
||||
setCustomTidalApiStatus("idle");
|
||||
void persistCustomTidalApi(nextValue);
|
||||
}} placeholder="https://your-hifi-api.example"/>
|
||||
<Button type="button" variant="outline" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
|
||||
{customTidalApiStatus === "checking" ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
|
||||
setCustomTidalApiStatus("idle");
|
||||
void persistCustomTidalApi("");
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4 text-destructive"/>
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
{customTidalApiStatus !== "idle" && (<p className={`text-xs ${customTidalApiStatus === "online"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: customTidalApiStatus === "offline"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"}`}>
|
||||
{customTidalApiStatus === "online"
|
||||
? "Custom HiFi API instance is online."
|
||||
: customTidalApiStatus === "offline"
|
||||
? "Custom HiFi API instance is offline or returned preview-only data."
|
||||
: "Checking custom HiFi API instance..."}
|
||||
</p>)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCustomTidalApiDialog(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will reset all settings to their default values. Your custom
|
||||
configurations will be lost.
|
||||
font list will be kept.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { getSettings, updateSettings } from "@/lib/settings";
|
||||
import { PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
|
||||
import { fetchCurrentIPInfo } from "@/lib/api";
|
||||
import type { CurrentIPInfo } from "@/types/api";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
@@ -24,7 +27,12 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
|
||||
"TM",
|
||||
"YE",
|
||||
]);
|
||||
interface SettingsUpdatedDetail {
|
||||
previewVolume?: number;
|
||||
}
|
||||
export function TitleBar() {
|
||||
const initialSettings = getSettings();
|
||||
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
|
||||
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
|
||||
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
|
||||
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
|
||||
@@ -33,6 +41,16 @@ export function TitleBar() {
|
||||
useEffect(() => {
|
||||
currentIPInfoRef.current = currentIPInfo;
|
||||
}, [currentIPInfo]);
|
||||
useEffect(() => {
|
||||
const handleSettingsUpdate = (event: Event) => {
|
||||
const updatedSettings = (event as CustomEvent<SettingsUpdatedDetail>).detail;
|
||||
if (updatedSettings && typeof updatedSettings.previewVolume === "number") {
|
||||
setPreviewVolume(updatedSettings.previewVolume);
|
||||
}
|
||||
};
|
||||
window.addEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
}, []);
|
||||
const loadCurrentIPInfo = async (options?: {
|
||||
silent?: boolean;
|
||||
}) => {
|
||||
@@ -88,6 +106,22 @@ export function TitleBar() {
|
||||
const handleClose = () => {
|
||||
Quit();
|
||||
};
|
||||
const handlePreviewVolumeChange = (value: number[]) => {
|
||||
const nextValue = value[0];
|
||||
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
|
||||
return;
|
||||
}
|
||||
setPreviewVolume(nextValue);
|
||||
window.dispatchEvent(new CustomEvent(PREVIEW_VOLUME_CHANGED_EVENT, { detail: nextValue }));
|
||||
};
|
||||
const handlePreviewVolumeCommit = (value: number[]) => {
|
||||
const nextValue = value[0];
|
||||
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
|
||||
return;
|
||||
}
|
||||
setPreviewVolume(nextValue);
|
||||
void updateSettings({ previewVolume: nextValue });
|
||||
};
|
||||
const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
|
||||
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
|
||||
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
|
||||
@@ -102,7 +136,17 @@ export function TitleBar() {
|
||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||
</MenubarTrigger>
|
||||
<MenubarContent align="end" className="min-w-[280px]">
|
||||
<MenubarContent align="end" className="min-w-70">
|
||||
<div className="px-2 py-1.5 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<MenubarLabel className="p-0">Preview Volume</MenubarLabel>
|
||||
<span className="text-xs font-medium text-muted-foreground tabular-nums">
|
||||
{previewVolume}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider value={[previewVolume]} min={0} max={100} step={5} onValueChange={handlePreviewVolumeChange} onValueCommit={handlePreviewVolumeCommit} aria-label="Preview volume"/>
|
||||
</div>
|
||||
<MenubarSeparator />
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<MenubarLabel className="p-0">Network</MenubarLabel>
|
||||
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
|
||||
@@ -112,7 +156,7 @@ export function TitleBar() {
|
||||
<div className="px-2 py-1.5 space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-[18px] rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
||||
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-4.5 rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
||||
<span className="font-mono text-xs truncate">
|
||||
{isLoadingCurrentIPInfo
|
||||
? "Detecting..."
|
||||
|
||||
@@ -37,14 +37,24 @@ function SelectContent({ className, children, position = "popper", align = "cent
|
||||
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}>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
function SelectItem({ className, children, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||
indicatorPosition?: "right" | "inline";
|
||||
trailingAction?: React.ReactNode;
|
||||
}) {
|
||||
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 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", indicatorPosition === "right" ? "pr-8" : "pr-2", trailingAction ? "pr-10" : undefined, className)} {...props}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{indicatorPosition === "inline" && (<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>)}
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
|
||||
{trailingAction}
|
||||
</span>) : indicatorPosition === "right" ? (<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>) : null}
|
||||
</SelectPrimitive.Item>);
|
||||
}
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min];
|
||||
return (<SliderPrimitive.Root data-slot="slider" defaultValue={defaultValue} value={value} min={min} max={max} className={cn("relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50", className)} {...props}>
|
||||
<SliderPrimitive.Track data-slot="slider-track" className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full rounded-full bg-primary"/>
|
||||
</SliderPrimitive.Track>
|
||||
{values.map((_, index) => (<SliderPrimitive.Thumb key={index} data-slot="slider-thumb" className="block size-4 shrink-0 rounded-full border-2 border-primary bg-background shadow-sm transition-[color,box-shadow] hover:shadow-md focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"/>))}
|
||||
</SliderPrimitive.Root>);
|
||||
}
|
||||
export { Slider };
|
||||
@@ -36,13 +36,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
||||
async function resolveTemplateISRC(settings: {
|
||||
folderTemplate?: string;
|
||||
filenameTemplate?: string;
|
||||
existingFileCheckMode?: string;
|
||||
}, spotifyId?: string): Promise<string> {
|
||||
if (!spotifyId) {
|
||||
return "";
|
||||
}
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const filenameTemplate = settings.filenameTemplate || "";
|
||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
||||
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||
folderTemplate.includes("{isrc}") ||
|
||||
filenameTemplate.includes("{isrc}");
|
||||
if (!shouldResolveISRC) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
@@ -52,26 +56,18 @@ async function resolveTemplateISRC(settings: {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function getTidalVariant(settings: any): "tidal" | "alt" {
|
||||
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
|
||||
}
|
||||
function isTidalAltVariant(settings: any): boolean {
|
||||
return getTidalVariant(settings) === "alt";
|
||||
}
|
||||
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
|
||||
if (isTidalAltVariant(settings)) {
|
||||
return "LOSSLESS";
|
||||
}
|
||||
if (mode === "auto") {
|
||||
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||
}
|
||||
return settings.tidalQuality || "LOSSLESS";
|
||||
}
|
||||
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
|
||||
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
|
||||
function shouldFetchStreamingURLs(order: string[]): boolean {
|
||||
return order.includes("amazon") || order.includes("tidal");
|
||||
}
|
||||
export function useDownload(region: string) {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||
@@ -83,10 +79,19 @@ export function useDownload(region: string) {
|
||||
artists: string;
|
||||
} | null>(null);
|
||||
const shouldStopDownloadRef = useRef(false);
|
||||
const updateBatchProgress = (completedCount: number, totalCount: number) => {
|
||||
const safeTotalCount = Math.max(0, totalCount);
|
||||
const safeCompletedCount = Math.min(Math.max(0, completedCount), safeTotalCount);
|
||||
setDownloadProgress(safeTotalCount > 0 ? Math.min(100, Math.round((safeCompletedCount / safeTotalCount) * 100)) : 0);
|
||||
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
|
||||
};
|
||||
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||
const service = settings.downloader;
|
||||
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
||||
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
||||
: undefined;
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
@@ -189,10 +194,8 @@ export function useDownload(region: string) {
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const tidalVariant = getTidalVariant(settings);
|
||||
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
@@ -209,9 +212,9 @@ export function useDownload(region: string) {
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "tidal",
|
||||
query,
|
||||
@@ -229,11 +232,11 @@ export function useDownload(region: string) {
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||
tidal_variant: tidalVariant,
|
||||
service_url: streamingURLs?.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
tidal_api_url: customTidalApi,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -246,17 +249,17 @@ export function useDownload(region: string) {
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||
logger.warning(`Tidal failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`${tidalLabel} error: ${err}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||
logger.error(`Tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -394,7 +397,7 @@ export function useDownload(region: string) {
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID,
|
||||
audio_format: audioFormat,
|
||||
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -475,10 +478,8 @@ export function useDownload(region: string) {
|
||||
}
|
||||
if (service === "auto") {
|
||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||
const tidalVariant = getTidalVariant(settings);
|
||||
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||
let streamingURLs: any = null;
|
||||
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||
try {
|
||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||
@@ -495,9 +496,9 @@ export function useDownload(region: string) {
|
||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||
const qobuzQuality = is24Bit ? "27" : "6";
|
||||
for (const s of order) {
|
||||
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||
try {
|
||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||
const response = await downloadTrack({
|
||||
service: "tidal",
|
||||
query,
|
||||
@@ -515,8 +516,7 @@ export function useDownload(region: string) {
|
||||
spotify_id: spotifyId,
|
||||
embed_lyrics: settings.embedLyrics,
|
||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||
tidal_variant: tidalVariant,
|
||||
service_url: streamingURLs?.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID,
|
||||
audio_format: tidalQuality,
|
||||
@@ -532,17 +532,17 @@ export function useDownload(region: string) {
|
||||
embed_genre: settings.embedGenre,
|
||||
});
|
||||
if (response.success) {
|
||||
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
||||
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||
return response;
|
||||
}
|
||||
const errMsg = response.error || response.message || "Failed";
|
||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||
lastResponse = response;
|
||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||
logger.warning(`Tidal failed, trying next...`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(`${tidalLabel} error: ${err}`);
|
||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||
logger.error(`Tidal error: ${err}`);
|
||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||
lastResponse = { success: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
@@ -679,7 +679,6 @@ export function useDownload(region: string) {
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID,
|
||||
audio_format: audioFormat,
|
||||
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
|
||||
spotify_track_number: spotifyTrackNumber,
|
||||
spotify_disc_number: spotifyDiscNumber,
|
||||
spotify_total_tracks: spotifyTotalTracks,
|
||||
@@ -747,6 +746,8 @@ export function useDownload(region: string) {
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("selected");
|
||||
setDownloadProgress(0);
|
||||
setDownloadRemainingCount(selectedTracks.length);
|
||||
setCurrentDownloadInfo(null);
|
||||
let outputDir = settings.downloadPath;
|
||||
const os = settings.operatingSystem;
|
||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||
@@ -815,7 +816,7 @@ export function useDownload(region: string) {
|
||||
let errorCount = 0;
|
||||
let skippedCount = existingSpotifyIDs.size;
|
||||
const total = selectedTracks.length;
|
||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||
updateBatchProgress(skippedCount, total);
|
||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||
if (shouldStopDownloadRef.current) {
|
||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||
@@ -868,12 +869,13 @@ export function useDownload(region: string) {
|
||||
}
|
||||
}
|
||||
const completedCount = skippedCount + successCount + errorCount;
|
||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||
updateBatchProgress(completedCount, total);
|
||||
}
|
||||
setDownloadingTrack(null);
|
||||
setCurrentDownloadInfo(null);
|
||||
setIsDownloading(false);
|
||||
setBulkDownloadType(null);
|
||||
updateBatchProgress(0, 0);
|
||||
shouldStopDownloadRef.current = false;
|
||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||
await CancelAllQueuedItems();
|
||||
@@ -922,6 +924,8 @@ export function useDownload(region: string) {
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("all");
|
||||
setDownloadProgress(0);
|
||||
setDownloadRemainingCount(tracksWithId.length);
|
||||
setCurrentDownloadInfo(null);
|
||||
let outputDir = settings.downloadPath;
|
||||
const os = settings.operatingSystem;
|
||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||
@@ -985,7 +989,7 @@ export function useDownload(region: string) {
|
||||
let errorCount = 0;
|
||||
let skippedCount = existingSpotifyIDs.size;
|
||||
const total = tracksWithId.length;
|
||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||
updateBatchProgress(skippedCount, total);
|
||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||
if (shouldStopDownloadRef.current) {
|
||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||
@@ -1035,12 +1039,13 @@ export function useDownload(region: string) {
|
||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
const completedCount = skippedCount + successCount + errorCount;
|
||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||
updateBatchProgress(completedCount, total);
|
||||
}
|
||||
setDownloadingTrack(null);
|
||||
setCurrentDownloadInfo(null);
|
||||
setIsDownloading(false);
|
||||
setBulkDownloadType(null);
|
||||
updateBatchProgress(0, 0);
|
||||
shouldStopDownloadRef.current = false;
|
||||
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
||||
await CancelQueued();
|
||||
@@ -1087,6 +1092,7 @@ export function useDownload(region: string) {
|
||||
};
|
||||
return {
|
||||
downloadProgress,
|
||||
downloadRemainingCount,
|
||||
isDownloading,
|
||||
downloadingTrack,
|
||||
bulkDownloadType,
|
||||
|
||||
@@ -9,13 +9,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
||||
async function resolveTemplateISRC(settings: {
|
||||
folderTemplate?: string;
|
||||
filenameTemplate?: string;
|
||||
existingFileCheckMode?: string;
|
||||
}, spotifyId?: string): Promise<string> {
|
||||
if (!spotifyId) {
|
||||
return "";
|
||||
}
|
||||
const folderTemplate = settings.folderTemplate || "";
|
||||
const filenameTemplate = settings.filenameTemplate || "";
|
||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
||||
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||
folderTemplate.includes("{isrc}") ||
|
||||
filenameTemplate.includes("{isrc}");
|
||||
if (!shouldResolveISRC) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||
import { getPreviewVolume } from "@/lib/preview";
|
||||
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
||||
import { toast } from "sonner";
|
||||
export function usePreview() {
|
||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
||||
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
|
||||
const currentPlaybackRef = useRef<PreviewPlayback | null>(null);
|
||||
const stopCurrentAudio = () => {
|
||||
if (!currentPlaybackRef.current) {
|
||||
return;
|
||||
}
|
||||
currentPlaybackRef.current.destroy();
|
||||
currentPlaybackRef.current = null;
|
||||
};
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
}
|
||||
stopCurrentAudio();
|
||||
};
|
||||
}, [currentAudio]);
|
||||
}, []);
|
||||
const playPreview = async (trackId: string, trackName: string) => {
|
||||
try {
|
||||
const currentAudio = currentPlaybackRef.current?.audio;
|
||||
if (playingTrack === trackId && currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
stopCurrentAudio();
|
||||
setPlayingTrack(null);
|
||||
setCurrentAudio(null);
|
||||
return;
|
||||
}
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
setCurrentAudio(null);
|
||||
stopCurrentAudio();
|
||||
setPlayingTrack(null);
|
||||
}
|
||||
setLoadingPreview(trackId);
|
||||
@@ -38,15 +40,18 @@ export function usePreview() {
|
||||
setLoadingPreview(null);
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(previewURL);
|
||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
|
||||
const audio = playback.audio;
|
||||
audio.addEventListener("loadeddata", () => {
|
||||
setLoadingPreview(null);
|
||||
setPlayingTrack(trackId);
|
||||
});
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayingTrack(null);
|
||||
setCurrentAudio(null);
|
||||
if (currentPlaybackRef.current?.audio === audio) {
|
||||
currentPlaybackRef.current.destroy();
|
||||
currentPlaybackRef.current = null;
|
||||
}
|
||||
});
|
||||
audio.addEventListener("error", () => {
|
||||
toast.error("Failed to play preview", {
|
||||
@@ -54,27 +59,27 @@ export function usePreview() {
|
||||
});
|
||||
setLoadingPreview(null);
|
||||
setPlayingTrack(null);
|
||||
setCurrentAudio(null);
|
||||
if (currentPlaybackRef.current?.audio === audio) {
|
||||
currentPlaybackRef.current.destroy();
|
||||
currentPlaybackRef.current = null;
|
||||
}
|
||||
});
|
||||
setCurrentAudio(audio);
|
||||
currentPlaybackRef.current = playback;
|
||||
await audio.play();
|
||||
}
|
||||
catch (error: any) {
|
||||
catch (error: unknown) {
|
||||
stopCurrentAudio();
|
||||
console.error("Preview error:", error);
|
||||
toast.error("Preview not available", {
|
||||
description: error?.message || `Could not load preview for "${trackName}"`,
|
||||
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`,
|
||||
});
|
||||
setLoadingPreview(null);
|
||||
setPlayingTrack(null);
|
||||
}
|
||||
};
|
||||
const stopPreview = () => {
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio.currentTime = 0;
|
||||
setCurrentAudio(null);
|
||||
setPlayingTrack(null);
|
||||
}
|
||||
stopCurrentAudio();
|
||||
setPlayingTrack(null);
|
||||
};
|
||||
return {
|
||||
playPreview,
|
||||
|
||||
@@ -10,19 +10,10 @@ export interface ApiSource {
|
||||
interface SpotiFLACNextSource {
|
||||
id: string;
|
||||
name: string;
|
||||
statusKey?: string;
|
||||
statusPrefix?: string;
|
||||
}
|
||||
type SpotiFLACNextStatusResponse = {
|
||||
tidal?: string;
|
||||
qobuz_a?: string;
|
||||
qobuz_b?: string;
|
||||
qobuz_c?: string;
|
||||
deezer_a?: string;
|
||||
deezer_b?: string;
|
||||
amazon_a?: string;
|
||||
amazon_b?: string;
|
||||
amazon_c?: string;
|
||||
apple?: string;
|
||||
};
|
||||
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
|
||||
export const API_SOURCES: ApiSource[] = [
|
||||
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||
@@ -30,13 +21,13 @@ export const API_SOURCES: ApiSource[] = [
|
||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||
];
|
||||
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||
{ id: "tidal", name: "Tidal" },
|
||||
{ id: "qobuz", name: "Qobuz" },
|
||||
{ id: "amazon", name: "Amazon Music" },
|
||||
{ id: "deezer", name: "Deezer" },
|
||||
{ id: "apple", name: "Apple Music" },
|
||||
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
|
||||
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
||||
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
|
||||
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
||||
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
||||
];
|
||||
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
|
||||
const SPOTIFLAC_NEXT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
||||
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
||||
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
||||
type ApiStatusState = {
|
||||
@@ -70,12 +61,25 @@ async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
|
||||
return value === "up" ? "online" : "offline";
|
||||
}
|
||||
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||
return values.some((value) => value === "up") ? "online" : "offline";
|
||||
}
|
||||
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
|
||||
if (source.statusKey) {
|
||||
const value = payload[source.statusKey];
|
||||
return typeof value === "string" ? [value] : [];
|
||||
}
|
||||
if (!source.statusPrefix) {
|
||||
return [];
|
||||
}
|
||||
const values: string[] = [];
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key.startsWith(source.statusPrefix) && typeof value === "string") {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -98,13 +102,10 @@ async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheck
|
||||
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
|
||||
}
|
||||
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
|
||||
return {
|
||||
tidal: statusFromNextValue(payload.tidal),
|
||||
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
|
||||
deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
|
||||
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
|
||||
apple: statusFromNextValue(payload.apple),
|
||||
};
|
||||
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
@@ -13,9 +13,6 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
|
||||
}
|
||||
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||
const req = new main.DownloadRequest(request);
|
||||
if (request.tidal_variant !== undefined) {
|
||||
(req as any).tidal_variant = request.tidal_variant;
|
||||
}
|
||||
if (request.use_single_genre !== undefined) {
|
||||
(req as any).use_single_genre = request.use_single_genre;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getPreviewVolume, PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
|
||||
export interface PreviewPlayback {
|
||||
audio: HTMLAudioElement;
|
||||
destroy: () => void;
|
||||
}
|
||||
export async function createPreviewPlayback(url: string, volume: number): Promise<PreviewPlayback> {
|
||||
const audio = new Audio(url);
|
||||
const applyVolume = (nextVolume: number) => {
|
||||
if (!Number.isFinite(nextVolume)) {
|
||||
return;
|
||||
}
|
||||
audio.volume = Math.min(1, Math.max(0, nextVolume));
|
||||
};
|
||||
applyVolume(volume);
|
||||
const handleSettingsUpdated = () => {
|
||||
applyVolume(getPreviewVolume());
|
||||
};
|
||||
const handlePreviewVolumeChanged = (event: Event) => {
|
||||
const nextVolumePercent = (event as CustomEvent<number>).detail;
|
||||
if (!Number.isFinite(nextVolumePercent)) {
|
||||
return;
|
||||
}
|
||||
applyVolume(nextVolumePercent / 100);
|
||||
};
|
||||
window.addEventListener("settingsUpdated", handleSettingsUpdated);
|
||||
window.addEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||
return {
|
||||
audio,
|
||||
destroy: () => {
|
||||
window.removeEventListener("settingsUpdated", handleSettingsUpdated);
|
||||
window.removeEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1 +1,10 @@
|
||||
import { getSettings } from "@/lib/settings";
|
||||
export const SPOTIFY_PREVIEW_VOLUME = 1;
|
||||
export const PREVIEW_VOLUME_CHANGED_EVENT = "previewVolumeChanged";
|
||||
export function getPreviewVolume(): number {
|
||||
const previewVolume = getSettings().previewVolume;
|
||||
if (!Number.isFinite(previewVolume)) {
|
||||
return SPOTIFY_PREVIEW_VOLUME;
|
||||
}
|
||||
return Math.min(1, Math.max(0, previewVolume / 100));
|
||||
}
|
||||
|
||||
+574
-236
@@ -1,15 +1,32 @@
|
||||
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } 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" | "bricolage-grotesque";
|
||||
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
|
||||
export type BuiltInFontFamily = "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" | "bricolage-grotesque";
|
||||
export type CustomFontFamily = `custom-${string}`;
|
||||
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
|
||||
export interface CustomFontOption {
|
||||
value: CustomFontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
url: string;
|
||||
}
|
||||
export type FontOption = {
|
||||
value: FontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
url?: string;
|
||||
};
|
||||
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";
|
||||
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 type ExistingFileCheckMode = "filename" | "isrc";
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
customTidalApi: string;
|
||||
linkResolver: "songstats" | "songlink";
|
||||
allowResolverFallback: boolean;
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
customFonts: CustomFontOption[];
|
||||
folderPreset: FolderPreset;
|
||||
folderTemplate: string;
|
||||
filenamePreset: FilenamePreset;
|
||||
@@ -22,7 +39,6 @@ export interface Settings {
|
||||
embedLyrics: boolean;
|
||||
embedMaxQualityCover: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS";
|
||||
tidalVariant: "tidal" | "alt";
|
||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||
qobuzQuality: "6" | "7" | "27";
|
||||
amazonQuality: "original";
|
||||
@@ -32,6 +48,8 @@ export interface Settings {
|
||||
createPlaylistFolder: boolean;
|
||||
playlistOwnerFolderName: boolean;
|
||||
createM3u8File: boolean;
|
||||
previewVolume: number;
|
||||
existingFileCheckMode: ExistingFileCheckMode;
|
||||
useFirstArtistOnly: boolean;
|
||||
useSingleGenre: boolean;
|
||||
embedGenre: boolean;
|
||||
@@ -42,54 +60,105 @@ 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}" },
|
||||
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}" },
|
||||
"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}" },
|
||||
"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}" },
|
||||
"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}" },
|
||||
custom: { label: "Custom...", template: "{artist}/{album}" },
|
||||
};
|
||||
export const FILENAME_PRESETS: Record<FilenamePreset, {
|
||||
label: string;
|
||||
template: string;
|
||||
}> = {
|
||||
"title": { label: "Title", template: "{title}" },
|
||||
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-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}" },
|
||||
"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 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: "{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: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
|
||||
{ key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
|
||||
{
|
||||
key: "{date}",
|
||||
description: "Release date (YYYY-MM-DD)",
|
||||
example: "2014-10-27",
|
||||
},
|
||||
{
|
||||
key: "{isrc}",
|
||||
description: "Track ISRC",
|
||||
example: "USUM71412345",
|
||||
},
|
||||
];
|
||||
function detectOS(): "Windows" | "linux/MacOS" {
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
if (platform.includes('win')) {
|
||||
if (platform.includes("win")) {
|
||||
return "Windows";
|
||||
}
|
||||
return "linux/MacOS";
|
||||
@@ -97,11 +166,13 @@ function detectOS(): "Windows" | "linux/MacOS" {
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
downloadPath: "",
|
||||
downloader: "auto",
|
||||
customTidalApi: "",
|
||||
linkResolver: "songlink",
|
||||
allowResolverFallback: true,
|
||||
theme: "yellow",
|
||||
themeMode: "auto",
|
||||
fontFamily: "google-sans",
|
||||
customFonts: [],
|
||||
folderPreset: "none",
|
||||
folderTemplate: "",
|
||||
filenamePreset: "title-artist",
|
||||
@@ -111,7 +182,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
embedLyrics: false,
|
||||
embedMaxQualityCover: false,
|
||||
operatingSystem: detectOS(),
|
||||
tidalVariant: "tidal",
|
||||
tidalQuality: "LOSSLESS",
|
||||
qobuzQuality: "6",
|
||||
amazonQuality: "original",
|
||||
@@ -121,42 +191,461 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
createPlaylistFolder: true,
|
||||
playlistOwnerFolderName: false,
|
||||
createM3u8File: false,
|
||||
previewVolume: 100,
|
||||
existingFileCheckMode: "filename",
|
||||
useFirstArtistOnly: false,
|
||||
useSingleGenre: false,
|
||||
embedGenre: false,
|
||||
redownloadWithSuffix: false,
|
||||
separator: "semicolon"
|
||||
separator: "semicolon",
|
||||
};
|
||||
export const FONT_OPTIONS: {
|
||||
value: FontFamily;
|
||||
label: string;
|
||||
fontFamily: string;
|
||||
}[] = [
|
||||
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
|
||||
{ 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: FontOption[] = [
|
||||
{
|
||||
value: "bricolage-grotesque",
|
||||
label: "Bricolage Grotesque",
|
||||
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
|
||||
},
|
||||
{
|
||||
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);
|
||||
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
|
||||
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
|
||||
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
|
||||
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
|
||||
const SETTINGS_KEY = "spotiflac-settings";
|
||||
let cachedSettings: Settings | null = null;
|
||||
type SettingsPayload = Partial<Settings> & {
|
||||
darkMode?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
|
||||
function extractGoogleFontInputUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
|
||||
if (hrefMatch?.[1]) {
|
||||
return hrefMatch[1];
|
||||
}
|
||||
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
|
||||
if (importMatch?.[1]) {
|
||||
return importMatch[1];
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
function coerceGoogleFontUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
function normalizeFontLabel(label: string): string {
|
||||
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
function slugifyFontLabel(label: string): string {
|
||||
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
|
||||
}
|
||||
function toFontFamilyCss(label: string): string {
|
||||
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return `"${escapedLabel}", system-ui, sans-serif`;
|
||||
}
|
||||
function buildGoogleFontsCssUrl(label: string): string {
|
||||
const url = new URL("https://fonts.googleapis.com/css2");
|
||||
url.searchParams.set("family", label);
|
||||
url.searchParams.set("display", "swap");
|
||||
return url.toString();
|
||||
}
|
||||
function extractSpecimenFontLabel(parsed: URL): string {
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
|
||||
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
|
||||
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
|
||||
}
|
||||
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
|
||||
if (parsed.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
|
||||
const label = extractSpecimenFontLabel(parsed);
|
||||
return label ? buildGoogleFontsCssUrl(label) : null;
|
||||
}
|
||||
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
|
||||
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.searchParams.getAll("family").length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.searchParams.has("display")) {
|
||||
parsed.searchParams.set("display", "swap");
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
|
||||
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
|
||||
if (!normalizedUrl) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new URL(normalizedUrl);
|
||||
const family = parsed.searchParams.getAll("family")[0];
|
||||
const label = normalizeFontLabel((family || "").split(":")[0] || "");
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
|
||||
label,
|
||||
fontFamily: toFontFamilyCss(label),
|
||||
url: normalizedUrl,
|
||||
};
|
||||
}
|
||||
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
|
||||
if (!Array.isArray(customFonts)) {
|
||||
return [];
|
||||
}
|
||||
const normalizedFonts: CustomFontOption[] = [];
|
||||
const seenValues = new Set<string>();
|
||||
const seenUrls = new Set<string>();
|
||||
for (const item of customFonts) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const rawUrl = (item as {
|
||||
url?: unknown;
|
||||
}).url;
|
||||
if (typeof rawUrl !== "string") {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseGoogleFontUrl(rawUrl);
|
||||
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
|
||||
continue;
|
||||
}
|
||||
seenValues.add(parsed.value);
|
||||
seenUrls.add(parsed.url);
|
||||
normalizedFonts.push(parsed);
|
||||
}
|
||||
return normalizedFonts;
|
||||
}
|
||||
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
|
||||
if (typeof fontFamily !== "string") {
|
||||
return DEFAULT_SETTINGS.fontFamily;
|
||||
}
|
||||
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
|
||||
return fontFamily as BuiltInFontFamily;
|
||||
}
|
||||
const customFont = customFonts.find((font) => font.value === fontFamily);
|
||||
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
|
||||
}
|
||||
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
|
||||
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
|
||||
}
|
||||
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
|
||||
const normalizedUrl = normalizeGoogleFontCssUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
let link = document.getElementById(id) as HTMLLinkElement | null;
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = id;
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (link.href !== normalizedUrl) {
|
||||
link.href = normalizedUrl;
|
||||
}
|
||||
}
|
||||
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
|
||||
for (const font of normalizeCustomFonts(customFonts)) {
|
||||
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
|
||||
}
|
||||
}
|
||||
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
|
||||
const fontOptions = getFontOptions(customFonts);
|
||||
loadCustomFontStylesheets(customFonts);
|
||||
const font = fontOptions.find((option) => option.value === fontFamily) ||
|
||||
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
|
||||
if (font) {
|
||||
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
||||
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
|
||||
document.body.style.fontFamily = font.fontFamily;
|
||||
}
|
||||
}
|
||||
async function persistCustomFontsInternal(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
|
||||
const normalizedFonts = normalizeCustomFonts(customFonts);
|
||||
await SaveFontsToBackend(normalizedFonts as unknown as Array<Record<string, unknown>>);
|
||||
if (cachedSettings) {
|
||||
cachedSettings = toNormalizedSettings({
|
||||
...cachedSettings,
|
||||
customFonts: normalizedFonts,
|
||||
});
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cachedSettings));
|
||||
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: cachedSettings }));
|
||||
}
|
||||
return normalizedFonts;
|
||||
}
|
||||
async function loadStoredCustomFonts(fallbackFonts?: unknown): Promise<CustomFontOption[]> {
|
||||
try {
|
||||
const storedFonts = await LoadFontsFromBackend();
|
||||
if (storedFonts !== null) {
|
||||
return normalizeCustomFonts(storedFonts);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
}
|
||||
const migratedFonts = normalizeCustomFonts(fallbackFonts);
|
||||
if (migratedFonts.length > 0) {
|
||||
try {
|
||||
return await persistCustomFontsInternal(migratedFonts);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to migrate custom fonts:", error);
|
||||
}
|
||||
}
|
||||
return migratedFonts;
|
||||
}
|
||||
export async function loadCustomFonts(): Promise<CustomFontOption[]> {
|
||||
return loadStoredCustomFonts(getSettings().customFonts);
|
||||
}
|
||||
export async function saveCustomFonts(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
|
||||
return persistCustomFontsInternal(customFonts);
|
||||
}
|
||||
function keepKnownSettings(settings: SettingsPayload): SettingsPayload {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const key of KNOWN_SETTINGS_KEYS) {
|
||||
if (key in settings) {
|
||||
normalized[key] = settings[key];
|
||||
}
|
||||
}
|
||||
return normalized as SettingsPayload;
|
||||
}
|
||||
function normalizePreviewVolume(volume: unknown): number {
|
||||
const parsed = typeof volume === "number"
|
||||
? volume
|
||||
: typeof volume === "string"
|
||||
? Number.parseFloat(volume)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_SETTINGS.previewVolume;
|
||||
}
|
||||
return Math.min(100, Math.max(0, Math.round(parsed)));
|
||||
}
|
||||
function normalizeCustomTidalApi(value: unknown): string {
|
||||
return typeof value === "string"
|
||||
? value.trim().replace(/\/+$/g, "")
|
||||
: "";
|
||||
}
|
||||
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
|
||||
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
|
||||
case "isrc":
|
||||
case "upc":
|
||||
return "isrc";
|
||||
default:
|
||||
return "filename";
|
||||
}
|
||||
}
|
||||
function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
|
||||
const normalized: SettingsPayload = { ...settings };
|
||||
if ("darkMode" in normalized && !("themeMode" in normalized)) {
|
||||
normalized.themeMode = normalized.darkMode ? "dark" : "light";
|
||||
delete normalized.darkMode;
|
||||
}
|
||||
if (!("folderPreset" in normalized) &&
|
||||
("artistSubfolder" in normalized || "albumSubfolder" in normalized)) {
|
||||
const hasArtist = Boolean(normalized.artistSubfolder);
|
||||
const hasAlbum = Boolean(normalized.albumSubfolder);
|
||||
if (hasArtist && hasAlbum) {
|
||||
normalized.folderPreset = "artist-album";
|
||||
normalized.folderTemplate = "{artist}/{album}";
|
||||
}
|
||||
else if (hasArtist) {
|
||||
normalized.folderPreset = "artist";
|
||||
normalized.folderTemplate = "{artist}";
|
||||
}
|
||||
else if (hasAlbum) {
|
||||
normalized.folderPreset = "album";
|
||||
normalized.folderTemplate = "{album}";
|
||||
}
|
||||
else {
|
||||
normalized.folderPreset = "none";
|
||||
normalized.folderTemplate = "";
|
||||
}
|
||||
}
|
||||
if (!("filenamePreset" in normalized) && "filenameFormat" in normalized) {
|
||||
const format = normalized.filenameFormat;
|
||||
if (format === "title-artist") {
|
||||
normalized.filenamePreset = "artist-title";
|
||||
normalized.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else if (format === "artist-title") {
|
||||
normalized.filenamePreset = "artist-title";
|
||||
normalized.filenameTemplate = "{artist} - {title}";
|
||||
}
|
||||
else {
|
||||
normalized.filenamePreset = "title";
|
||||
normalized.filenameTemplate = "{title}";
|
||||
}
|
||||
}
|
||||
delete normalized.tidalVariant;
|
||||
if (!("tidalQuality" in normalized)) {
|
||||
normalized.tidalQuality = "LOSSLESS";
|
||||
}
|
||||
if (!("qobuzQuality" in normalized)) {
|
||||
normalized.qobuzQuality = "6";
|
||||
}
|
||||
if (!("amazonQuality" in normalized)) {
|
||||
normalized.amazonQuality = "original";
|
||||
}
|
||||
if (!("autoOrder" in normalized)) {
|
||||
normalized.autoOrder = "tidal-qobuz-amazon";
|
||||
}
|
||||
if (!("autoQuality" in normalized)) {
|
||||
normalized.autoQuality = "16";
|
||||
}
|
||||
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
|
||||
if (!("allowFallback" in normalized)) {
|
||||
normalized.allowFallback = true;
|
||||
}
|
||||
if (!("linkResolver" in normalized)) {
|
||||
normalized.linkResolver = "songlink";
|
||||
}
|
||||
if (!("allowResolverFallback" in normalized)) {
|
||||
normalized.allowResolverFallback = true;
|
||||
}
|
||||
if (!("createPlaylistFolder" in normalized)) {
|
||||
normalized.createPlaylistFolder = true;
|
||||
}
|
||||
if (!("playlistOwnerFolderName" in normalized)) {
|
||||
normalized.playlistOwnerFolderName = false;
|
||||
}
|
||||
if (!("createM3u8File" in normalized)) {
|
||||
normalized.createM3u8File = false;
|
||||
}
|
||||
normalized.previewVolume = normalizePreviewVolume(normalized.previewVolume);
|
||||
normalized.existingFileCheckMode = normalizeExistingFileCheckMode(normalized.existingFileCheckMode);
|
||||
if (!("useFirstArtistOnly" in normalized)) {
|
||||
normalized.useFirstArtistOnly = false;
|
||||
}
|
||||
if (!("useSingleGenre" in normalized)) {
|
||||
normalized.useSingleGenre = false;
|
||||
}
|
||||
if (!("embedGenre" in normalized)) {
|
||||
normalized.embedGenre = false;
|
||||
}
|
||||
if (!("separator" in normalized)) {
|
||||
normalized.separator = "semicolon";
|
||||
}
|
||||
if (!("redownloadWithSuffix" in normalized)) {
|
||||
normalized.redownloadWithSuffix = false;
|
||||
}
|
||||
normalized.operatingSystem = detectOS();
|
||||
const normalizedCustomFonts = normalizeCustomFonts(normalized.customFonts);
|
||||
normalized.customFonts = normalizedCustomFonts;
|
||||
normalized.fontFamily = normalizeFontFamily(normalized.fontFamily, normalizedCustomFonts);
|
||||
return normalized;
|
||||
}
|
||||
function toNormalizedSettings(settings: SettingsPayload): Settings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...keepKnownSettings(normalizeSettingsPayload(settings)),
|
||||
} as Settings;
|
||||
}
|
||||
async function persistSettingsInternal(settings: Settings, notify = true): Promise<void> {
|
||||
cachedSettings = settings;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
const settingsForBackend = { ...settings } as Record<string, unknown>;
|
||||
delete settingsForBackend.customFonts;
|
||||
await SaveToBackend(settingsForBackend);
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: settings }));
|
||||
}
|
||||
}
|
||||
async function fetchDefaultPath(): Promise<string> {
|
||||
try {
|
||||
const data = await GetDefaults();
|
||||
@@ -167,90 +656,11 @@ async function fetchDefaultPath(): Promise<string> {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const SETTINGS_KEY = "spotiflac-settings";
|
||||
let cachedSettings: Settings | null = null;
|
||||
function getSettingsFromLocalStorage(): Settings {
|
||||
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 (!('tidalVariant' in parsed)) {
|
||||
parsed.tidalVariant = "tidal";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "original";
|
||||
}
|
||||
if (!('autoOrder' in parsed)) {
|
||||
parsed.autoOrder = "tidal-qobuz-amazon";
|
||||
}
|
||||
if (!('autoQuality' in parsed)) {
|
||||
parsed.autoQuality = "16";
|
||||
}
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('playlistOwnerFolderName' in parsed)) {
|
||||
parsed.playlistOwnerFolderName = false;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
if (!('redownloadWithSuffix' in parsed)) {
|
||||
parsed.redownloadWithSuffix = false;
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -259,108 +669,25 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
export function getSettings(): Settings {
|
||||
if (cachedSettings)
|
||||
if (cachedSettings) {
|
||||
return cachedSettings;
|
||||
}
|
||||
return getSettingsFromLocalStorage();
|
||||
}
|
||||
export async function loadSettings(): Promise<Settings> {
|
||||
try {
|
||||
const backendSettings = await LoadSettings();
|
||||
if (backendSettings) {
|
||||
const parsed = backendSettings as any;
|
||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||
delete parsed.darkMode;
|
||||
const parsed = backendSettings as SettingsPayload;
|
||||
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
|
||||
cachedSettings = toNormalizedSettings({
|
||||
...parsed,
|
||||
customFonts,
|
||||
});
|
||||
if ("customFonts" in parsed) {
|
||||
await persistSettingsInternal(cachedSettings, false);
|
||||
}
|
||||
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 (!('tidalVariant' in parsed)) {
|
||||
parsed.tidalVariant = "tidal";
|
||||
}
|
||||
if (!('qobuzQuality' in parsed)) {
|
||||
parsed.qobuzQuality = "6";
|
||||
}
|
||||
if (!('amazonQuality' in parsed)) {
|
||||
parsed.amazonQuality = "original";
|
||||
}
|
||||
if (!('autoOrder' in parsed)) {
|
||||
parsed.autoOrder = "tidal-qobuz-amazon";
|
||||
}
|
||||
if (!('autoQuality' in parsed)) {
|
||||
parsed.autoQuality = "16";
|
||||
}
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('createPlaylistFolder' in parsed)) {
|
||||
parsed.createPlaylistFolder = true;
|
||||
}
|
||||
if (!('playlistOwnerFolderName' in parsed)) {
|
||||
parsed.playlistOwnerFolderName = false;
|
||||
}
|
||||
if (!('createM3u8File' in parsed)) {
|
||||
parsed.createM3u8File = false;
|
||||
}
|
||||
if (!('useFirstArtistOnly' in parsed)) {
|
||||
parsed.useFirstArtistOnly = false;
|
||||
}
|
||||
if (!('useSingleGenre' in parsed)) {
|
||||
parsed.useSingleGenre = false;
|
||||
}
|
||||
if (!('embedGenre' in parsed)) {
|
||||
parsed.embedGenre = false;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
if (!('redownloadWithSuffix' in parsed)) {
|
||||
parsed.redownloadWithSuffix = false;
|
||||
}
|
||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
return cachedSettings!;
|
||||
return cachedSettings;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -368,12 +695,19 @@ export async function loadSettings(): Promise<Settings> {
|
||||
}
|
||||
const local = getSettingsFromLocalStorage();
|
||||
try {
|
||||
await SaveToBackend(local as any);
|
||||
cachedSettings = local;
|
||||
const customFonts = await loadStoredCustomFonts(local.customFonts);
|
||||
const localWithFonts = toNormalizedSettings({
|
||||
...local,
|
||||
customFonts,
|
||||
});
|
||||
await persistSettingsInternal(localWithFonts, false);
|
||||
cachedSettings = localWithFonts;
|
||||
return localWithFonts;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to migrate settings to backend:", error);
|
||||
}
|
||||
cachedSettings = local;
|
||||
return local;
|
||||
}
|
||||
export interface TemplateData {
|
||||
@@ -389,8 +723,9 @@ export interface TemplateData {
|
||||
playlist?: string;
|
||||
}
|
||||
export function parseTemplate(template: string, data: TemplateData): string {
|
||||
if (!template)
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
let result = template;
|
||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||
@@ -414,10 +749,8 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||
}
|
||||
export async function saveSettings(settings: Settings): Promise<void> {
|
||||
try {
|
||||
cachedSettings = settings;
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
await SaveToBackend(settings as any);
|
||||
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
|
||||
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
|
||||
await persistSettingsInternal(normalizedSettings);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
@@ -431,7 +764,12 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
|
||||
}
|
||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||
const defaultPath = await fetchDefaultPath();
|
||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||
const customFonts = await loadCustomFonts();
|
||||
const defaultSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
downloadPath: defaultPath,
|
||||
customFonts,
|
||||
};
|
||||
await saveSettings(defaultSettings);
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,6 @@ export interface DownloadRequest {
|
||||
release_date?: string;
|
||||
cover_url?: string;
|
||||
tidal_api_url?: string;
|
||||
tidal_variant?: "tidal" | "alt";
|
||||
output_dir?: string;
|
||||
audio_format?: string;
|
||||
folder_name?: string;
|
||||
|
||||
Reference in New Issue
Block a user