This commit is contained in:
429Enjoyer
2026-05-20 05:53:45 +07:00
parent 0093df6016
commit b3ebef5ab9
30 changed files with 2147 additions and 1257 deletions
+8 -5
View File
@@ -24,15 +24,16 @@ import { AudioResamplerPage } from "@/components/AudioResamplerPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import { AboutPage } from "@/components/AboutPage";
import { OtherProjects } from "@/components/OtherProjects";
import { HistoryPage } from "@/components/HistoryPage";
import { SupportPage } from "@/components/SupportPage";
import type { HistoryItem } from "@/components/FetchHistory";
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { buildPlaylistFolderName } from "@/lib/playlist";
@@ -198,7 +199,7 @@ function App() {
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
ensureSpotiFLACNextStatusCheckStarted();
ensureApiStatusCheckStarted();
void loadHistory();
return () => {
mediaQuery.removeEventListener("change", handleChange);
@@ -528,8 +529,10 @@ function App() {
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
case "debug":
return <DebugLoggerPage />;
case "about":
return <AboutPage />;
case "projects":
return <OtherProjects />;
case "support":
return <SupportPage />;
case "history":
return <HistoryPage onHistorySelect={(cachedData) => {
metadata.loadFromCache(cachedData);
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 749.3 227.1" fill="#FFFFFF">
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
<path d="M222.8,85.4c0-3.4,2.5-5.6,6.4-5.6h18.6c16.9,0,28.3,9.3,28.3,22.9s-11.3,23.3-28.3,23.3h-2.6c-6.5,0-9.8,3.4-9.8,8.8v15.2c0,4.3-2.5,7-6.3,7s-6.3-2.7-6.3-7v-64.6ZM235.4,104.7c0,6.8,3.5,10.1,10.1,10.1h1.6c9.3,0,16.1-3.8,16.1-12.1s-6.8-12.1-16.1-12.1h-1.6c-6.6,0-10.1,3.2-10.1,10.1v4.1ZM276.1,151.1c0,3.6,2.5,5.9,6.3,5.9s4.8-1.6,6.1-5l2.3-6.1c1.8-4.9,5.1-7.1,8.6-7.1h20.5c3.6,0,6.8,2.3,8.6,7.1l2.3,6.1c1.3,3.4,3.6,5,6.1,5,3.8,0,6.3-2.4,6.3-5.9s-.2-2.2-.6-3.4l-24.5-63.8c-1.5-3.9-5-5.8-8.3-5.8s-6.8,1.9-8.3,5.8l-24.5,63.8c-.4,1.2-.6,2.4-.6,3.4ZM300,122.1c0-1.2.3-2.3.9-3.9l4.6-12.9c.9-2.5,2.4-3.7,4.1-3.7s3.2,1.2,4.1,3.7l4.6,12.9c.5,1.6.9,2.7.9,3.9,0,3.2-1.8,5.5-6.7,5.5h-5.8c-4.9,0-6.7-2.3-6.7-5.5ZM339,85.6c0-3.5,2.5-5.8,6.5-5.8h49.7c4,0,6.5,2.4,6.5,5.8s-2.5,5.8-6.5,5.8h-8.3c-6.6,0-10.2,3.4-10.2,11v47.4c0,4.4-2.5,7.1-6.4,7.1s-6.4-2.7-6.4-7.1v-47.4c0-7.7-3.6-11-10.2-11h-8.3c-4,0-6.5-2.4-6.5-5.8ZM413.4,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-17.2c0-4.9,2.8-6.9,6.3-6.9h.9c2.3,0,4.5,1.4,5.9,3.5l16.4,24.1c1.5,2.3,3.5,3.6,5.9,3.6s5.8-2.7,5.8-5.9-.4-2.7-1.4-4.1l-10.9-15.3c-1.3-1.8-1.8-3.4-1.8-4.6,0-2.7,2.4-4.6,5.2-6.7,5.1-3.8,10.6-8.8,10.6-18.3s-10.4-22.3-27.5-22.3h-21.7c-3.9,0-6.3,2.3-6.3,5.6v64.6ZM425.9,103.7v-3.2c0-7,3.7-9.9,9.3-9.9h5.4c9.3,0,15.2,3.5,15.2,11.5s-6.3,11.7-15.6,11.7h-5.1c-5.6,0-9.3-2.9-9.3-9.9ZM484.8,149.8v-64.4c0-3.4,2.4-5.6,6.3-5.6h40.9c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-25.8c-5.1,0-8.8,3-8.8,8.8v2.4c0,5.7,3.7,8.8,8.8,8.8h20c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-19.2c-5.1,0-9.5,3.1-9.5,9.5v3c0,6.4,4.4,9.5,9.5,9.5h25.1c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-40.9c-3.9,0-6.3-2.3-6.3-5.6ZM545.7,117.6c0-23.3,17.5-39.4,38-39.4s38,16.1,38,39.4-17.5,39.4-38,39.4-38-16.1-38-39.4ZM559.9,117.6c0,16.4,9.7,26.9,23.8,26.9s23.8-10.5,23.8-26.9-9.7-26.9-23.8-26.9-23.8,10.4-23.8,26.9ZM636.8,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-33.1c0-4,2.4-5.9,4.9-5.9s3.6,1.1,4.8,3l20.8,34.7c2.8,4.8,5.4,8.3,10.7,8.3s8.8-3.7,8.8-9.6v-62.2c0-4.3-2.5-7-6.3-7s-6.3,2.7-6.3,7v33.1c0,4-2.4,5.9-4.9,5.9s-3.6-1.1-4.8-3l-20.8-34.7c-2.8-4.8-5.4-8.3-10.7-8.3s-8.8,3.7-8.8,9.6v62.2Z"/>
<path d="M169.2,87.5c0-16.7-13-30.3-28.2-35.2-18.9-6.1-43.8-5.2-61.9,3.3-21.9,10.3-28.7,32.9-29,55.5-.2,18.5,1.6,67.4,29.2,67.7,20.5.3,23.5-26.1,33-38.8,6.7-9,15.4-11.6,26.1-14.2,18.4-4.5,30.9-19,30.8-38.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.4.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
</svg>

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+23 -17
View File
@@ -1,14 +1,14 @@
import { Button } from "@/components/ui/button";
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
import { useApiStatus } from "@/hooks/useApiStatus";
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
if (status === "online") {
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
}
if (status === "offline") {
return <XCircle className="h-5 w-5 text-destructive"/>;
return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
}
return null;
}
@@ -19,9 +19,6 @@ function renderPlatformIcon(type: string) {
if (type === "amazon") {
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
if (type === "musicbrainz") {
return <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
if (type === "deezer") {
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
@@ -31,27 +28,30 @@ function renderPlatformIcon(type: string) {
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
}
export function ApiStatusTab() {
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
return (<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllCurrent()} disabled={isCheckingCurrent} className="gap-2">
{isCheckingCurrent ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{sources.map((source) => {
const status = statuses[source.id] || "idle";
const isChecking = checkingSources[source.id] === true;
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
{renderPlatformIcon(source.type)}
<p className="font-medium leading-none">{source.name}</p>
</div>
<div className="flex items-center">{renderStatusIcon(status)}</div>
<div className="flex items-center">{renderStatusIndicator(status)}</div>
</div>
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
Check
</Button>
</div>);
})}
</div>
@@ -60,7 +60,13 @@ export function ApiStatusTab() {
<div className="border-t"/>
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
<Button variant="outline" size="sm" onClick={() => void checkAllNext()} disabled={isCheckingNext} className="gap-2">
{isCheckingNext ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
Check
</Button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
@@ -70,7 +76,7 @@ export function ApiStatusTab() {
{renderPlatformIcon(source.id)}
<p className="font-medium leading-none">{source.name}</p>
</div>
<div className="flex items-center">{renderStatusIcon(status)}</div>
<div className="flex items-center">{renderStatusIndicator(status)}</div>
</div>);
})}
</div>
@@ -1,24 +1,18 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { openExternal } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
import { Star, GitFork, Clock, Download, Info } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XIcon from "@/assets/x.webp";
import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
import { langColors } from "@/assets/github-lang-colors";
const browserExtensionItems = [
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
];
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
const projectCardHeaderClass = "px-5 gap-1.5";
@@ -26,10 +20,8 @@ const projectCardContentClass = "px-5";
const projectBodyClass = "text-[13px] leading-snug";
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
export function AboutPage() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
export function OtherProjects() {
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
const [copiedUsdt, setCopiedUsdt] = useState(false);
useEffect(() => {
const fetchRepoStats = async () => {
const CACHE_KEY = "github_repo_stats_v4";
@@ -181,24 +173,10 @@ export function AboutPage() {
};
return (<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">About</h2>
<h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
<Blocks className="h-4 w-4"/>
Other Projects
</Button>
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
<Heart className="h-4 w-4"/>
Support Me
</Button>
</div>
<div className="flex-1 min-h-0">
{activeTab === "projects" && (<div className="pr-1.5">
<div className="flex-1 min-h-0 pr-1.5">
<div className="grid gap-2 grid-cols-3">
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
<CardHeader className={projectCardHeaderClass}>
@@ -249,7 +227,7 @@ export function AboutPage() {
Note
</div>
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
This project released as a token of appreciation for those who have supported SpotiFLAC on Ko-fi. Its not a paid product, but its shared privately through a supporter-only post.
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. Its not a paid product, but its shared privately through a supporter-only post.
</p>
</div>
</CardContent>)}
@@ -313,7 +291,7 @@ export function AboutPage() {
</CardContent>)}
</Card>
<div className="flex h-full flex-col gap-1.5">
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
<CardHeader className={projectCardHeaderClass}>
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
@@ -339,55 +317,6 @@ export function AboutPage() {
</Card>
</div>
</div>
</div>)}
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Enjoying the project? You can support ongoing development by buying me a coffee.
</p>
</div>
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
Support me on Ko-fi
</Button>
</div>
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="p-2 bg-white rounded-xl shadow-sm border">
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Crypto donations are also accepted. Scan the QR code or copy the address.
</p>
</div>
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
</div>)}
</div>
</div>);
}
+381 -362
View File
@@ -6,10 +6,10 @@ 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, Plus, Trash2, ExternalLink } from "lucide-react";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink, PlugZap, Download, Tags } 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, 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 { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, hasConfiguredCustomTidalApi, sanitizeAutoOrder, 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, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -33,6 +33,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
const fontOptions = getFontOptions(tempSettings.customFonts);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const hasCustomTidalInstanceConfigured = hasConfiguredCustomTidalApi(tempSettings.customTidalApi);
const effectiveDownloader = !hasCustomTidalInstanceConfigured && tempSettings.downloader === "tidal"
? "auto"
: tempSettings.downloader;
const effectiveAutoOrder = sanitizeAutoOrder(tempSettings.autoOrder, hasCustomTidalInstanceConfigured);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
flushSync(() => {
@@ -96,7 +101,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
}, []);
const handleSave = async () => {
await saveSettings(tempSettings);
setSavedSettings(tempSettings);
const persistedSettings = getSettings();
setSavedSettings(persistedSettings);
setTempSettings(persistedSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
};
@@ -184,13 +191,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
customTidalApi: normalizedValue,
};
await saveSettings(nextSavedSettings);
setSavedSettings((prev) => ({
...prev,
customTidalApi: normalizedValue,
}));
const nextSavedState = getSettings();
setSavedSettings(nextSavedState);
setTempSettings((prev) => ({
...prev,
customTidalApi: normalizedValue,
customTidalApi: nextSavedState.customTidalApi,
downloader: !hasConfiguredCustomTidalApi(nextSavedState.customTidalApi) && prev.downloader === "tidal"
? nextSavedState.downloader
: prev.downloader,
autoOrder: sanitizeAutoOrder(prev.autoOrder, hasConfiguredCustomTidalApi(nextSavedState.customTidalApi)),
}));
}, []);
const handleCheckCustomTidalApi = async () => {
@@ -216,7 +225,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
toast.error(`Failed to check HiFi API instance: ${error}`);
}
};
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
const [activeTab, setActiveTab] = useState<"general" | "download" | "files" | "metadata" | "status">("general");
return (<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">Settings</h1>
@@ -248,33 +257,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<MonitorCog className="h-4 w-4"/>
General
</Button>
<Button variant={activeTab === "download" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("download")} className="rounded-b-none gap-2">
<Download className="h-4 w-4"/>
Download
</Button>
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
<FolderCog className="h-4 w-4"/>
File Management
Files
</Button>
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
<Button variant={activeTab === "metadata" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("metadata")} className="rounded-b-none gap-2">
<Tags className="h-4 w-4"/>
Metadata
</Button>
<Button variant={activeTab === "status" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("status")} className="rounded-b-none gap-2">
<Router className="h-4 w-4"/>
Status
</Button>
</div>
<div className="flex-1 overflow-y-auto pt-4">
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{activeTab === "general" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
...prev,
downloadPath: e.target.value,
}))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
@@ -309,7 +312,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<div className="flex flex-wrap items-center gap-2">
@@ -357,6 +362,218 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Label>
</div>
</div>
</div>)}
{activeTab === "download" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<Label>Tidal Source</Label>
<div className="flex items-center gap-2 flex-wrap">
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
<TidalIcon />
Add Instance
</Button>
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
{tempSettings.customTidalApi}
</span>)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<div className="flex items-center gap-3 flex-wrap">
<Select value={effectiveDownloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
...prev,
downloader: value,
}))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
{hasCustomTidalInstanceConfigured && (<SelectItem value="tidal">
<span className="flex items-center gap-2">
<TidalIcon />
Tidal
</span>
</SelectItem>)}
<SelectItem value="qobuz">
<span className="flex items-center gap-2">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center gap-2">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
{effectiveDownloader === "auto" && (<>
<Select value={effectiveAutoOrder} onValueChange={(value: string) => setTempSettings((prev) => ({
...prev,
autoOrder: value,
}))}>
<SelectTrigger className="h-9 w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-fit min-w-max">
{hasCustomTidalInstanceConfigured && (<>
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon-qobuz">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-amazon-tidal">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal-qobuz">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz-tidal">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
</>)}
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
</SelectContent>
</Select>
<Select value={tempSettings.autoQuality || "16"} onValueChange={handleAutoQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
<SelectItem value="24">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>
</>)}
{effectiveDownloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>)}
{effectiveDownloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
<SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
</SelectContent>
</Select>)}
{effectiveDownloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit - 24-bit/44.1kHz - 192kHz
</div>)}
</div>
{((effectiveDownloader === "tidal" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(effectiveDownloader === "qobuz" &&
tempSettings.qobuzQuality === "27") ||
(effectiveDownloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowFallback: checked,
}))}/>
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
Allow Quality Fallback (16-bit)
</Label>
</div>)}
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
@@ -384,277 +601,37 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
<div className="flex items-center gap-3 pt-2">
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowResolverFallback: checked,
}))}/>
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
Allow Fallback
</Label>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<div className="flex items-center gap-3 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
...prev,
downloader: value,
}))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center gap-2">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center gap-2">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center gap-2">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
{tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({
...prev,
autoOrder: value,
}))}>
<SelectTrigger className="h-9 w-fit min-w-35">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tidal-qobuz-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon-qobuz">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-amazon-tidal">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal-qobuz">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz-tidal">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-qobuz">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="tidal-amazon">
<span className="flex items-center gap-1.5">
<TidalIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-tidal">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="qobuz-amazon">
<span className="flex items-center gap-1.5">
<QobuzIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<AmazonIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-tidal">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<TidalIcon className="fill-current"/>
</span>
</SelectItem>
<SelectItem value="amazon-qobuz">
<span className="flex items-center gap-1.5">
<AmazonIcon className="fill-current"/>
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
<QobuzIcon className="fill-current"/>
</span>
</SelectItem>
</SelectContent>
</Select>
<Select value={tempSettings.autoQuality || "16"} onValueChange={handleAutoQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
<SelectItem value="24">24-bit/48kHz</SelectItem>
</SelectContent>
</Select>
</>)}
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">
24-bit/48kHz
</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
<SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit - 24-bit/44.1kHz - 192kHz
</div>)}
</div>
{((tempSettings.downloader === "tidal" &&
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">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowFallback: checked,
}))}/>
<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-2"/>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedMaxQualityCover: checked,
}))}/>
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
Embed Max Quality Cover
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedGenre: checked,
}))}/>
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
Embed Genre
</Label>
</div>
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useSingleGenre: checked,
}))}/>
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
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>
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
Allow Resolver Fallback
</Label>
</div>
</div>
</div>)}
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
{activeTab === "files" && (<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-8 items-start">
<div className="space-y-4 lg:pr-8 lg:border-r">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
...prev,
downloadPath: e.target.value,
}))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
@@ -742,31 +719,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Create M3U8 Playlist File
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useFirstArtistOnly: checked,
}))}/>
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
Use First Artist Only
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
redownloadWithSuffix: checked,
}))}/>
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
Redownload With Suffix
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-4 lg:pl-0">
<div className="space-y-2">
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
@@ -784,22 +739,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">
Variables:{" "}
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">
Variables:{" "}
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings((prev) => ({
...prev,
@@ -809,42 +764,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
: preset.template,
}));
}}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
{label}
</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
filenameTemplate: e.target.value,
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div>
<div className="space-y-2 pt-2">
<Label className="text-sm">Separator</Label>
<div className="flex gap-2">
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
...prev,
separator: value,
}))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comma">Comma (,)</SelectItem>
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
{label}
</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
...prev,
filenameTemplate: e.target.value,
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div>
</div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview:{" "}
<span className="font-mono">
{tempSettings.filenameTemplate
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview:{" "}
<span className="font-mono">
{tempSettings.filenameTemplate
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
.replace(/\{album_artist\}/g, "Kendrick Lamar")
.replace(/\{album\}/g, "Black Panther")
@@ -858,10 +795,92 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</span>
</p>)}
</div>
<div className="space-y-2">
<Label className="text-sm">Separator</Label>
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
...prev,
separator: value,
}))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comma">Comma (,)</SelectItem>
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
redownloadWithSuffix: checked,
}))}/>
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
Redownload With Suffix
</Label>
</div>
</div>
</div>)}
{activeTab === "api" && (<ApiStatusTab />)}
{activeTab === "metadata" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<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,
embedMaxQualityCover: checked,
}))}/>
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
Embed Max Quality Cover
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedGenre: checked,
}))}/>
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
Embed Genre
</Label>
</div>
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useSingleGenre: checked,
}))}/>
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
Use Single Genre
</Label>
</div>)}
</div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
useFirstArtistOnly: checked,
}))}/>
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
Use First Artist Only
</Label>
</div>
</div>
</div>)}
{activeTab === "status" && (<ApiStatusTab />)}
</div>
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
@@ -915,7 +934,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<DialogContent className="sm:max-w-md [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between gap-3">
<DialogTitle>Custom Instance</DialogTitle>
<DialogTitle>Tidal Source</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"/>
@@ -932,8 +951,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
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 type="button" variant="outline" className="gap-2" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
{customTidalApiStatus === "checking" ? "Checking..." : <><PlugZap className="h-4 w-4"/>Check</>}
</Button>
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
setCustomTidalApiStatus("idle");
+10 -10
View File
@@ -6,18 +6,18 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
import { BugReportIcon } from "@/components/ui/bug-report-icon";
import { CoffeeIcon } from "@/components/ui/coffee";
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks-icon";
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
import { ToolCaseIcon } from "@/components/ui/tool-case";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
@@ -100,7 +100,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<DropdownMenuTrigger asChild>
<TooltipTrigger asChild>
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
<BlocksIcon size={20} loop={true}/>
<ToolCaseIcon size={20}/>
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
@@ -134,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
<GithubIcon size={20}/>
<BugReportIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
@@ -176,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
<BadgeAlertIcon size={20}/>
<Button variant={currentPage === "projects" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "projects" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("projects")}>
<BlocksIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>About</p>
<p>Other Projects</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<Button variant={currentPage === "support" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "support" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("support")}>
<CoffeeIcon size={20} loop={true}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Support me on Ko-fi</p>
<p>Support Me</p>
</TooltipContent>
</Tooltip>
</div>
+97
View File
@@ -0,0 +1,97 @@
import { useState } from "react";
import { CircleCheck, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
import KofiLogo from "@/assets/ko-fi.gif";
import KofiSvg from "@/assets/kofi_symbol.svg";
import PatreonLogo from "@/assets/patreon.svg";
import PatreonSymbol from "@/assets/patreon_symbol.svg";
import UsdtBarcode from "@/assets/usdt.jpg";
export function SupportPage() {
const [copiedUsdt, setCopiedUsdt] = useState(false);
const [copiedEmail, setCopiedEmail] = useState(false);
return (<div className="flex flex-col space-y-3">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
</div>
<div className="flex flex-col items-center justify-center p-4">
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
<div className="flex flex-col items-center space-y-4">
<div className="h-32 flex items-center justify-center w-full relative">
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
</div>
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Buy me a coffee to help keep development going.
</p>
</div>
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
Support me on Ko-fi
</Button>
</div>
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center w-full px-4">
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
</div>
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Join on Patreon to help fund the project and follow updates.
</p>
</div>
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
Support me on Patreon
</Button>
</div>
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
<div className="flex flex-col items-center space-y-4 w-full">
<div className="h-32 flex items-center justify-center">
<div className="rounded-xl border bg-white p-2 shadow-sm">
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
</div>
</div>
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
<p className="text-sm text-muted-foreground text-center px-4">
Prefer crypto? Use the QR code or wallet address below.
</p>
</div>
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
<code className="truncate text-xs font-mono text-muted-foreground" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
</code>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
setCopiedUsdt(true);
setTimeout(() => setCopiedUsdt(false), 500);
}}>
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
</Button>
</div>
</div>
</div>
<div className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
If you have any questions or need help with donating, feel free to reach out via{" "}
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
Telegram
</button>{" "}
or{" "}
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
navigator.clipboard.writeText("hi@afkarxyz.fyi");
setCopiedEmail(true);
setTimeout(() => setCopiedEmail(false), 500);
}}>
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
</button>
.
</div>
</div>
</div>);
}
+1 -1
View File
@@ -176,7 +176,7 @@ export function TitleBar() {
</div>)}
</div>
<MenubarSeparator />
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
<MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
<Globe className="w-4 h-4 opacity-70"/>
<span>Website</span>
</MenubarItem>
@@ -1,61 +0,0 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface BadgeAlertIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const ICON_VARIANTS: Variants = {
normal: { scale: 1, rotate: 0 },
animate: {
scale: [1, 1.1, 1.1, 1.1, 1],
rotate: [0, -3, 3, -2, 2, 0],
transition: {
duration: 0.5,
times: [0, 0.2, 0.4, 0.6, 1],
ease: "easeInOut",
},
},
};
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start("animate"),
stopAnimation: () => controls.start("normal"),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
controls.start("animate");
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
controls.start("normal");
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
<line x1="12" x2="12" y1="8" y2="12"/>
<line x1="12" x2="12.01" y1="16" y2="16"/>
</motion.svg>
</div>);
});
BadgeAlertIcon.displayName = "BadgeAlertIcon";
export { BadgeAlertIcon };
@@ -0,0 +1,132 @@
"use client";
import type { Transition, Variants } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState, type HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type ReportIconMode = "bug" | "bulb";
interface BugReportIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
loop?: boolean;
}
const LOOP_INTERVAL_MS = 2200;
const GROUP_VARIANTS: Variants = {
hidden: {
opacity: 0,
},
visible: {
opacity: 1,
transition: {
duration: 0.2,
ease: [0, 0, 0.2, 1],
},
},
exit: {
opacity: 0,
transition: {
duration: 0.18,
ease: [0.4, 0, 1, 1],
},
},
};
const DRAW_VARIANTS: Variants = {
hidden: {
pathLength: 0,
opacity: 0,
},
visible: {
pathLength: 1,
opacity: 1,
},
exit: {
pathLength: 1,
opacity: 0,
},
};
function createDrawTransition(delay = 0, duration = 0.36): Transition {
return {
duration,
delay,
ease: [0.4, 0, 0.2, 1],
opacity: { delay },
};
}
function BugPaths() {
return (<>
<motion.path d="m8 2 1.88 1.88" transition={createDrawTransition(0)} variants={DRAW_VARIANTS}/>
<motion.path d="M14.12 3.88 16 2" transition={createDrawTransition(0.04)} variants={DRAW_VARIANTS}/>
<motion.path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" transition={createDrawTransition(0.08)} variants={DRAW_VARIANTS}/>
<motion.path d="M6.53 9A4 4 0 0 1 3 5" transition={createDrawTransition(0.14)} variants={DRAW_VARIANTS}/>
<motion.path d="M17.47 9A4 4 0 0 0 21 5" transition={createDrawTransition(0.18)} variants={DRAW_VARIANTS}/>
<motion.path d="M12 20v-9" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
<motion.path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" transition={createDrawTransition(0.3, 0.42)} variants={DRAW_VARIANTS}/>
<motion.path d="M22 13h-4" transition={createDrawTransition(0.42)} variants={DRAW_VARIANTS}/>
<motion.path d="M6 13H2" transition={createDrawTransition(0.46)} variants={DRAW_VARIANTS}/>
<motion.path d="M21 21a4 4 0 0 0-3.81-4" transition={createDrawTransition(0.52)} variants={DRAW_VARIANTS}/>
<motion.path d="M3 21a4 4 0 0 1 3.81-4" transition={createDrawTransition(0.56)} variants={DRAW_VARIANTS}/>
</>);
}
function BulbPaths() {
return (<>
<motion.path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" transition={createDrawTransition(0, 0.46)} variants={DRAW_VARIANTS}/>
<motion.path d="M9 18h6" transition={createDrawTransition(0.16)} variants={DRAW_VARIANTS}/>
<motion.path d="M10 22h4" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
</>);
}
function ReportIconGroup({ mode }: { mode: ReportIconMode }) {
return (<motion.g animate="visible" exit="exit" initial="hidden" variants={GROUP_VARIANTS}>
{mode === "bug" ? <BugPaths/> : <BulbPaths/>}
</motion.g>);
}
function StaticBugIcon() {
return (<g>
<path d="m8 2 1.88 1.88"/>
<path d="M14.12 3.88 16 2"/>
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
<path d="M6.53 9A4 4 0 0 1 3 5"/>
<path d="M17.47 9A4 4 0 0 0 21 5"/>
<path d="M12 20v-9"/>
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/>
<path d="M22 13h-4"/>
<path d="M6 13H2"/>
<path d="M21 21a4 4 0 0 0-3.81-4"/>
<path d="M3 21a4 4 0 0 1 3.81-4"/>
</g>);
}
function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
const [mode, setMode] = useState<ReportIconMode>("bug");
useEffect(() => {
if (!loop) {
setMode("bug");
return;
}
const intervalId = window.setInterval(() => {
setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
}, LOOP_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, [loop]);
return (<div className={cn("flex items-center justify-center", className)} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
{loop ? (<AnimatePresence>
<ReportIconGroup key={mode} mode={mode}/>
</AnimatePresence>) : (<StaticBugIcon/>)}
</svg>
</div>);
}
export { BugReportIcon };
-102
View File
@@ -1,102 +0,0 @@
"use client";
import type { Variants } from "motion/react";
import { motion, useAnimation } from "motion/react";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.9, 1],
transition: {
duration: 0.4,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
draw: {
pathLength: [0, 1],
rotate: 0,
transition: {
duration: 0.5,
},
},
wag: {
pathLength: 1,
rotate: [0, -15, 15, -10, 10, -5, 5],
transition: {
duration: 2.5,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
},
},
};
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const bodyControls = useAnimation();
const tailControls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: async () => {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
},
stopAnimation: () => {
bodyControls.start("normal");
tailControls.start("normal");
},
};
});
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e);
}
else {
bodyControls.start("animate");
await tailControls.start("draw");
tailControls.start("wag");
}
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e);
}
else {
bodyControls.start("normal");
tailControls.start("normal");
}
}, [bodyControls, tailControls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
</svg>
</div>);
});
GithubIcon.displayName = "GithubIcon";
export { GithubIcon };
+89
View File
@@ -0,0 +1,89 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ToolCaseIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DRAW_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const HANDLE_VARIANTS: Variants = {
normal: {
scaleX: 1,
originX: '50%',
},
animate: {
scaleX: [0.6, 1.1, 1],
originX: '50%',
transition: {
duration: 0.45,
ease: 'easeInOut',
},
},
};
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
ToolCaseIcon.displayName = 'ToolCaseIcon';
export { ToolCaseIcon };
+3 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { API_SOURCES, checkApiStatus, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
import { API_SOURCES, checkApiStatus, checkCurrentApiStatusesOnly, checkSpotiFLACNextStatusesOnly, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
export function useApiStatus() {
const [state, setState] = useState(getApiStatusState);
useEffect(() => {
@@ -11,5 +11,7 @@ export function useApiStatus() {
...state,
sources: API_SOURCES,
checkOne: (sourceId: string) => checkApiStatus(sourceId),
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
};
}
+8 -6
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
import { logger } from "@/lib/logger";
@@ -86,10 +86,11 @@ export function useDownload(region: string) {
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 allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem;
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
const customTidalApi = allowTidal && typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined;
let outputDir = settings.downloadPath;
@@ -193,7 +194,7 @@ export function useDownload(region: string) {
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
}
if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
@@ -416,7 +417,8 @@ export function useDownload(region: string) {
return singleServiceResponse;
};
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, 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 allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
@@ -477,7 +479,7 @@ export function useDownload(region: string) {
}
}
if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
+236 -60
View File
@@ -1,25 +1,43 @@
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
import { CheckAPIStatus, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource {
id: string;
type: string;
name: string;
url: string;
}
interface SpotiFLACNextSource {
id: string;
name: string;
statusKey?: string;
statusPrefix?: string;
}
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
type ApiStatusTargetReport = {
target?: string;
label?: string;
online?: boolean;
message?: string;
};
type ApiStatusReport = {
type?: string;
online?: boolean;
require_all?: boolean;
details?: ApiStatusTargetReport[];
};
export const API_SOURCES: ApiSource[] = [
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
];
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
@@ -27,43 +45,101 @@ export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
{ id: "apple", name: "Apple Music", statusKey: "apple" },
];
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;
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a";
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise<ApiStatusReport> => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL);
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
type ApiStatusState = {
checkingSources: Record<string, boolean>;
statuses: Record<string, ApiCheckStatus>;
nextStatuses: Record<string, ApiCheckStatus>;
};
let apiStatusState: ApiStatusState = {
checkingSources: {},
statuses: {},
nextStatuses: {},
};
let activeCheckCurrentOnly: Promise<void> | null = null;
let activeCheckNextOnly: Promise<void> | null = null;
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
const activeSourceChecks = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
function emitApiStatusChange() {
for (const listener of listeners) {
listener();
}
}
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
apiStatusState = updater(apiStatusState);
emitApiStatusChange();
}
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
try {
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
void LogStatusConsole(level, message);
}
catch {
return "offline";
return;
}
}
function logStatusInfo(message: string): void {
sendStatusConsole("info", message);
}
function logStatusWarning(message: string): void {
sendStatusConsole("warning", message);
}
function logStatusError(message: string): void {
sendStatusConsole("error", message);
}
function truncateStatusMessage(message?: string, maxLen = 180): string {
const trimmed = (message || "").trim();
if (trimmed.length <= maxLen) {
return trimmed;
}
return trimmed.slice(0, maxLen) + "...";
}
function logQobuzStatusReport(report: ApiStatusReport): void {
const details = Array.isArray(report.details) ? report.details : [];
if (details.length === 0) {
logStatusWarning("[Status][Qobuz] No provider details were returned.");
return;
}
const onlineCount = details.filter((detail) => detail.online === true).length;
logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`);
for (const detail of details) {
const label = detail.label || detail.target || "Unknown provider";
const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : "";
if (detail.online) {
logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`);
}
else {
logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`);
}
}
if (report.online) {
logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`);
}
else {
logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are 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];
@@ -80,9 +156,11 @@ function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: Spoti
}
return values;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
function getCurrentAmazonStatus(payload: SpotiFLACNextStatusResponse): ApiCheckStatus {
return payload[SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY] === "up" ? "online" : "offline";
}
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
const current = currentStatuses[source.id];
@@ -90,57 +168,142 @@ function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckSta
return acc;
}, {});
}
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
}
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
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;
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
try {
return await fetchSpotiFLACNextStatusesOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
function hasCurrentResults(): boolean {
return API_SOURCES.some((source) => {
const status = apiStatusState.statuses[source.id];
return status === "online" || status === "offline";
});
}
function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline";
});
}
async function fetchSpotiFLACStatusPayloadOnce(): Promise<SpotiFLACNextStatusResponse> {
const response = await withTimeout(fetch(SPOTIFLAC_STATUS_URL, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
},
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
if (!response.ok) {
throw new Error(`SpotiFLAC status returned ${response.status}`);
}
return (await response.json()) as SpotiFLACNextStatusResponse;
}
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
if (activeStatusPayloadFetch) {
return activeStatusPayloadFetch;
}
activeStatusPayloadFetch = (async () => {
let lastError: unknown = null;
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
try {
return await fetchSpotiFLACStatusPayloadOnce();
}
catch (error) {
lastError = error;
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
})();
try {
return await activeStatusPayloadFetch;
}
finally {
activeStatusPayloadFetch = null;
}
}
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
try {
if (source.id === "tidal") {
const customTidalApi = getSettings().customTidalApi;
if (!hasConfiguredCustomTidalApi(customTidalApi)) {
logStatusWarning("[Status][Tidal] Marked maintenance because no custom Tidal instance is configured.");
return "offline";
}
const isOnline = await withTimeout(CheckCustomTidalAPI(customTidalApi), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
if (source.id === "amazon") {
const payload = await fetchSpotiFLACStatusPayload();
return getCurrentAmazonStatus(payload);
}
if (source.id === "qobuz") {
logStatusInfo("[Status][Qobuz] Checking current SpotiFLAC providers...");
const report = await withTimeout(CheckAPIStatusReport(source.type, source.url), CHECK_TIMEOUT_MS, `API status report timed out after 10 seconds for ${source.name}`);
logQobuzStatusReport(report);
return report.online ? "online" : "offline";
}
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
catch (error) {
if (source.id === "qobuz") {
logStatusError(`[Status][Qobuz] Provider check failed: ${error instanceof Error ? error.message : String(error)}`);
}
return "offline";
}
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
const payload = await fetchSpotiFLACStatusPayload();
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
return acc;
}, {});
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function checkCurrentApiStatusesOnly(): Promise<void> {
if (activeCheckCurrentOnly) {
return activeCheckCurrentOnly;
}
activeCheckCurrentOnly = (async () => {
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
})();
try {
await activeCheckCurrentOnly;
}
finally {
activeCheckCurrentOnly = null;
}
}
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
if (activeCheckNextOnly) {
return activeCheckNextOnly;
}
activeCheckNextOnly = (async () => {
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({
@@ -150,11 +313,8 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
...checkingNextStatuses,
},
}));
try {
setApiStatusState((current) => ({
...current,
nextStatuses: { ...current.nextStatuses },
}));
const nextStatuses = await checkSpotiFLACNextStatuses();
setApiStatusState((current) => ({
...current,
@@ -170,26 +330,40 @@ export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
}));
}
finally {
activeCheckNextOnly = null;
}
})();
return activeCheckNextOnly;
try {
await activeCheckNextOnly;
}
finally {
activeCheckNextOnly = null;
}
}
export function ensureSpotiFLACNextStatusCheckStarted(): void {
export function ensureApiStatusCheckStarted(): void {
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
void checkCurrentApiStatusesOnly();
}
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
void checkSpotiFLACNextStatusesOnly();
}
}
export function ensureSpotiFLACNextStatusCheckStarted(): void {
ensureApiStatusCheckStarted();
}
export async function checkApiStatus(sourceId: string): Promise<void> {
const source = API_SOURCES.find((item) => item.id === sourceId);
if (!source) {
return;
}
const activeCheck = activeSourceChecks.get(sourceId);
if (activeCheck) {
return activeCheck;
}
const task = (async () => {
setApiStatusState((current) => ({
...current,
@@ -202,6 +376,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
[sourceId]: "checking",
},
}));
try {
const status = await checkSourceStatus(source);
setApiStatusState((current) => ({
@@ -223,6 +398,7 @@ export async function checkApiStatus(sourceId: string): Promise<void> {
activeSourceChecks.delete(sourceId);
}
})();
activeSourceChecks.set(sourceId, task);
return task;
}
+32 -2
View File
@@ -185,7 +185,7 @@ export const DEFAULT_SETTINGS: Settings = {
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original",
autoOrder: "tidal-qobuz-amazon",
autoOrder: "qobuz-amazon",
autoQuality: "16",
allowFallback: true,
createPlaylistFolder: true,
@@ -521,6 +521,33 @@ function normalizeCustomTidalApi(value: unknown): string {
? value.trim().replace(/\/+$/g, "")
: "";
}
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
return normalizeCustomTidalApi(value).startsWith("https://");
}
export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string {
const allowedServices = allowTidal
? new Set(["tidal", "qobuz", "amazon"])
: new Set(["qobuz", "amazon"]);
const fallbackOrder = allowTidal ? "tidal-qobuz-amazon" : "qobuz-amazon";
if (typeof order !== "string") {
return fallbackOrder;
}
const normalized = order
.split("-")
.map((part) => part.trim().toLowerCase())
.filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index);
return normalized.length >= 2 ? normalized.join("-") : fallbackOrder;
}
function normalizeDownloader(value: unknown, allowTidal: boolean): Settings["downloader"] {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "tidal") {
return allowTidal ? "tidal" : "auto";
}
if (normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
return normalized;
}
return DEFAULT_SETTINGS.downloader;
}
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
case "isrc":
@@ -583,12 +610,15 @@ function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
normalized.amazonQuality = "original";
}
if (!("autoOrder" in normalized)) {
normalized.autoOrder = "tidal-qobuz-amazon";
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
}
if (!("autoQuality" in normalized)) {
normalized.autoQuality = "16";
}
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
const allowTidal = hasConfiguredCustomTidalApi(normalized.customTidalApi);
normalized.downloader = normalizeDownloader(normalized.downloader, allowTidal);
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder, allowTidal);
if (!("allowFallback" in normalized)) {
normalized.allowFallback = true;
}