.clickable variables

This commit is contained in:
afkarxyz
2026-04-13 21:17:07 +07:00
parent 1b00badd93
commit e79622751d
7 changed files with 266 additions and 62 deletions
+6 -1
View File
@@ -414,7 +414,12 @@ function App() {
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
const trackId = track.spotify_id || "";
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}} onBack={metadata.resetMetadata}/>);
}
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
+53 -6
View File
@@ -12,6 +12,7 @@ import { useState } from "react";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { parseTemplate, type TemplateData } from "@/lib/settings";
import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
@@ -77,6 +78,47 @@ interface AlbumInfoProps {
}
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
const settings = getSettings();
const albumArtistNames = splitArtistNames(albumInfo.artists);
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
const clickableAlbumArtists = (() => {
const artistsByName = new Map<string, {
id: string;
name: string;
external_urls: string;
}>();
for (const track of trackList) {
const clickableTrackArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
for (const artist of clickableTrackArtists) {
const normalizedName = artist.name.trim().toLowerCase();
if (!normalizedName || !artist.external_urls || artistsByName.has(normalizedName)) {
continue;
}
artistsByName.set(normalizedName, artist);
}
}
return albumArtistNames.map((name) => {
const normalizedName = name.trim().toLowerCase();
const matchedArtist = artistsByName.get(normalizedName);
if (matchedArtist) {
return {
...matchedArtist,
name,
};
}
if (albumArtistNames.length === 1 && albumInfo.artist_id && albumInfo.artist_url) {
return {
id: albumInfo.artist_id,
name,
external_urls: albumInfo.artist_url,
};
}
return {
id: "",
name,
external_urls: "",
};
});
})();
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
const handleDownloadAlbumCover = async () => {
if (!albumInfo.images)
@@ -162,13 +204,18 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
<p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
id: albumInfo.artist_id!,
name: albumInfo.artists,
external_urls: albumInfo.artist_url!,
<span className="font-medium">
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
{onArtistClick && artist.external_urls ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{albumInfo.artists}
</span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
{artist.name}
</span>) : (artist.name)}
{index < clickableAlbumArtists.length - 1 && artistSeparator}
</span>)) : albumInfo.artists}
</span>
<span></span>
<span>{albumInfo.release_date}</span>
<span></span>
+33 -3
View File
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { usePreview } from "@/hooks/usePreview";
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
import { buildClickableArtists } from "@/lib/artist-links";
interface TrackInfoProps {
track: TrackMetadata & {
album_name: string;
@@ -31,10 +32,22 @@ interface TrackInfoProps {
onCheckAvailability?: (spotifyId: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void;
onAlbumClick?: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onBack?: () => void;
}
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onAlbumClick, onArtistClick, onBack, }: TrackInfoProps) {
const { playPreview, loadingPreview, playingTrack } = usePreview();
const hasAlbumClick = !!(onAlbumClick && track.album_id && track.album_url);
const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
@@ -69,13 +82,30 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
<p className="text-lg text-muted-foreground">
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{artist.name}
</span>) : (artist.name)}
{index < clickableArtists.length - 1 && ", "}
</span>)) : track.artists}
</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{track.album_name}</p>
<p className="font-medium truncate">{hasAlbumClick ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick?.({
id: track.album_id!,
name: track.album_name,
external_urls: track.album_url!,
})}>
{track.album_name}
</span>) : (track.album_name)}</p>
</div>
{track.plays && (<div>
<p className="text-xs text-muted-foreground">Total Plays</p>
+16 -22
View File
@@ -7,6 +7,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { usePreview } from "@/hooks/usePreview";
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
import { buildClickableArtists } from "@/lib/artist-links";
interface TrackListProps {
tracks: TrackMetadata[];
searchQuery: string;
@@ -249,29 +250,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
</div>
<span className="text-sm text-muted-foreground">
{track.artists_data && track.artists_data.length > 0 ? ((() => {
const artistNames = track.artists.split(", ").map(name => name.trim());
return artistNames.map((name, i) => {
const artistData = track.artists_data![i];
const hasArtistData = artistData && artistData.id && artistData.external_urls;
return (<span key={artistData?.id || i}>
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artistData.id,
name: name,
external_urls: artistData.external_urls,
{(() => {
const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
if (clickableArtists.length === 0) {
return track.artists;
}
return clickableArtists.map((artist, i) => (<span key={`${artist.id || artist.name}-${i}`}>
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: artist.id,
name: artist.name,
external_urls: artist.external_urls,
})}>
{name}
</span>) : (name)}
{i < artistNames.length - 1 && ", "}
</span>);
});
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
id: track.artist_id!,
name: track.artists,
external_urls: track.artist_url!,
})}>
{track.artists}
</span>) : (track.artists)}
{artist.name}
</span>) : (artist.name)}
{i < clickableArtists.length - 1 && ", "}
</span>));
})()}
</span>
</div>
</div>
+23 -3
View File
@@ -3,7 +3,7 @@ import { getSettings } from "@/lib/settings";
import { fetchSpotifyMetadata } from "@/lib/api";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { logger } from "@/lib/logger";
import { AddFetchHistory } from "../../wailsjs/go/main/App";
import { AddFetchHistory, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime";
import type { SpotifyMetadataResponse } from "@/types/api";
export function useMetadata() {
@@ -20,6 +20,19 @@ export function useMetadata() {
external_urls: string;
} | null>(null);
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
const resolveArtistUrlBySearch = async (artistName: string): Promise<string | null> => {
const query = artistName.trim();
if (!query) {
return null;
}
const results = await SearchSpotifyByType({
query,
search_type: "artist",
limit: 1,
offset: 0,
});
return results[0]?.external_urls || null;
};
useEffect(() => {
if (loading) {
fetchedCount.current = 0;
@@ -262,10 +275,17 @@ export function useMetadata() {
external_urls: string;
}) => {
logger.debug(`artist clicked: ${artist.name}`);
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
const resolvedArtistUrl = artist.external_urls.trim() || (await resolveArtistUrlBySearch(artist.name)) || "";
if (!resolvedArtistUrl) {
toast.error(`Artist not found: ${artist.name}`);
return "";
}
const artistUrl = resolvedArtistUrl.includes("/discography")
? resolvedArtistUrl
: resolvedArtistUrl.replace(/\/$/, "") + "/discography/all";
setPendingArtistName(artist.name);
await fetchMetadataDirectly(artistUrl);
return artistUrl;
return resolvedArtistUrl;
};
const handleConfirmAlbumFetch = async () => {
if (!selectedAlbum)
+45
View File
@@ -0,0 +1,45 @@
import type { ArtistSimple } from "@/types/api";
export interface ClickableArtist {
id: string;
name: string;
external_urls: string;
}
export function splitArtistNames(value: string): string[] {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
const parts = trimmed.split(/\s*[;,]\s*/).map((part) => part.trim()).filter(Boolean);
return parts.length > 0 ? parts : [trimmed];
}
export function buildClickableArtists(artists: string, artistsData?: ArtistSimple[], fallbackArtistId?: string, fallbackArtistUrl?: string): ClickableArtist[] {
const names = splitArtistNames(artists);
if (names.length === 0) {
return [];
}
return names.map((name, index) => {
const artistData = artistsData?.[index];
if (artistData && (artistData.id || artistData.external_urls)) {
return {
id: artistData.id || "",
name,
external_urls: artistData.external_urls || "",
};
}
if (names.length === 1) {
return {
id: fallbackArtistId || "",
name,
external_urls: fallbackArtistUrl || "",
};
}
return {
id: "",
name,
external_urls: "",
};
});
}