Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
@@ -0,0 +1,21 @@
import { Search as SearchIcon } from "lucide-react";
interface EmptyStateProps {
hasSearched: boolean;
isLoading: boolean;
}
export function EmptyState({ hasSearched, isLoading }: EmptyStateProps) {
// Don't show empty state while loading or if search has been performed
if (isLoading || hasSearched) {
return null;
}
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<SearchIcon className="w-16 h-16 text-gray-700 mb-4" />
<h3 className="text-xl font-bold text-white mb-2">Search your library</h3>
<p className="text-gray-400">Use the search bar above to find music, artists, and albums</p>
</div>
);
}
@@ -0,0 +1,47 @@
import Link from "next/link";
import { Disc3 } from "lucide-react";
import { api } from "@/lib/api";
import { Album } from "../types";
interface LibraryAlbumsGridProps {
albums: Album[];
}
export function LibraryAlbumsGrid({ albums }: LibraryAlbumsGridProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4" data-tv-section="search-results-albums">
{albums.slice(0, 6).map((album, index) => (
<Link
key={album.id}
href={`/album/${album.id}`}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
>
<div className="bg-[#121212] hover:bg-[#181818] transition-all p-4 rounded-lg group cursor-pointer">
<div className="aspect-square bg-[#181818] rounded-md mb-4 flex items-center justify-center overflow-hidden">
{album.coverUrl || album.albumId ? (
<img
src={api.getCoverArtUrl(
album.coverUrl || album.albumId,
300
)}
alt={album.title}
className="w-full h-full object-cover group-hover:scale-110 transition-all"
/>
) : (
<Disc3 className="w-12 h-12 text-gray-600" />
)}
</div>
<h3 className="text-base font-bold text-white line-clamp-1 mb-1">
{album.title}
</h3>
<p className="text-sm text-[#b3b3b3] line-clamp-1">
{album.artist?.name}
</p>
</div>
</Link>
))}
</div>
);
}
@@ -0,0 +1,62 @@
import Link from "next/link";
import Image from "next/image";
import { Music } from "lucide-react";
import { Podcast } from "../types";
import { api } from "@/lib/api";
interface LibraryPodcastsGridProps {
podcasts: Podcast[];
}
// Always proxy images through the backend for caching and mobile compatibility
const getProxiedImageUrl = (imageUrl: string | undefined): string | null => {
if (!imageUrl) return null;
return api.getCoverArtUrl(imageUrl, 300);
};
export function LibraryPodcastsGrid({ podcasts }: LibraryPodcastsGridProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4" data-tv-section="search-results-podcasts">
{podcasts.slice(0, 6).map((podcast, index) => {
const imageUrl = getProxiedImageUrl(podcast.imageUrl);
return (
<Link
key={podcast.id}
href={`/podcasts/${podcast.id}`}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
>
<div className="bg-[#121212] hover:bg-[#181818] transition-all p-4 rounded-lg group cursor-pointer">
<div className="aspect-square bg-[#181818] rounded-md mb-4 flex items-center justify-center overflow-hidden relative">
{imageUrl ? (
<Image
src={imageUrl}
alt={podcast.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
className="object-cover group-hover:scale-110 transition-all"
unoptimized
/>
) : (
<Music className="w-12 h-12 text-gray-600" />
)}
</div>
<h3 className="text-base font-bold text-white line-clamp-1 mb-1">
{podcast.title}
</h3>
<p className="text-sm text-[#b3b3b3] line-clamp-1">
{podcast.author || "Podcast"}
</p>
{podcast.episodeCount && podcast.episodeCount > 0 && (
<p className="text-xs text-gray-500 mt-1">
{podcast.episodeCount} episodes
</p>
)}
</div>
</Link>
);
})}
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { Play, Pause } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useAudio } from "@/lib/audio-context";
import { api } from "@/lib/api";
import { cn } from "@/utils/cn";
import { formatTime } from "@/utils/formatTime";
import type { LibraryTrack } from "../types";
interface LibraryTracksListProps {
tracks: LibraryTrack[];
}
export function LibraryTracksList({ tracks }: LibraryTracksListProps) {
const { currentTrack, isPlaying, playTracks, pause, resume } = useAudio();
if (!tracks || tracks.length === 0) {
return null;
}
const handlePlayTrack = (track: LibraryTrack, index: number) => {
// Format tracks for playback
const formattedTracks = tracks.map((t) => ({
id: t.id,
title: t.title,
duration: t.duration,
artist: {
id: t.album.artist.id,
name: t.album.artist.name,
},
album: {
id: t.album.id,
title: t.album.title,
coverArt: t.album.coverUrl,
},
}));
if (currentTrack?.id === track.id) {
// Toggle play/pause if clicking the same track
if (isPlaying) {
pause();
} else {
resume();
}
} else {
// Play from this track
playTracks(formattedTracks, index);
}
};
return (
<div className="space-y-1">
{tracks.slice(0, 10).map((track, index) => {
const isCurrentTrack = currentTrack?.id === track.id;
const isPlayingThis = isCurrentTrack && isPlaying;
const coverUrl = track.album.coverUrl
? api.getCoverArtUrl(track.album.coverUrl, 48)
: null;
return (
<div
key={track.id}
className={cn(
"flex items-center gap-3 p-2 rounded-md group transition-colors",
isCurrentTrack
? "bg-white/10"
: "hover:bg-white/5"
)}
>
{/* Play Button / Track Number */}
<button
onClick={() => handlePlayTrack(track, index)}
className="w-8 h-8 flex items-center justify-center flex-shrink-0"
>
{isPlayingThis ? (
<Pause className="w-4 h-4 text-[#ecb200]" />
) : isCurrentTrack ? (
<Play className="w-4 h-4 text-[#ecb200] ml-0.5" />
) : (
<>
<span className="text-sm text-gray-400 group-hover:hidden">
{index + 1}
</span>
<Play className="w-4 h-4 text-white hidden group-hover:block ml-0.5" />
</>
)}
</button>
{/* Cover Art */}
<div className="w-10 h-10 bg-[#282828] rounded overflow-hidden flex-shrink-0">
{coverUrl ? (
<Image
src={coverUrl}
alt={track.album.title}
width={40}
height={40}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="text-gray-500 text-xs"></span>
</div>
)}
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-medium truncate",
isCurrentTrack ? "text-[#ecb200]" : "text-white"
)}
>
{track.title}
</p>
<p className="text-xs text-gray-400 truncate">
<Link
href={`/artist/${track.album.artist.mbid || track.album.artist.id}`}
className="hover:underline hover:text-white"
onClick={(e) => e.stopPropagation()}
>
{track.album.artist.name}
</Link>
<span className="mx-1"></span>
<Link
href={`/album/${track.album.id}`}
className="hover:underline hover:text-white"
onClick={(e) => e.stopPropagation()}
>
{track.album.title}
</Link>
</p>
</div>
{/* Duration */}
<span className="text-sm text-gray-400 flex-shrink-0">
{formatTime(track.duration)}
</span>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,77 @@
import { Download } from "lucide-react";
import { cn } from "@/utils/cn";
import { FilterTab } from "../types";
interface SearchFiltersProps {
filterTab: FilterTab;
onFilterChange: (tab: FilterTab) => void;
soulseekEnabled: boolean;
hasSearched: boolean;
}
export function SearchFilters({
filterTab,
onFilterChange,
soulseekEnabled,
hasSearched,
}: SearchFiltersProps) {
if (!hasSearched) {
return null;
}
return (
<div className="flex gap-2 mb-8" data-tv-section="search-filters">
<button
data-tv-card
data-tv-card-index={0}
tabIndex={0}
onClick={() => onFilterChange("all")}
className={cn(
"px-4 py-2 text-sm font-bold rounded-full transition-all",
filterTab === "all" ? "bg-white text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
)}
>
All
</button>
<button
data-tv-card
data-tv-card-index={1}
tabIndex={0}
onClick={() => onFilterChange("library")}
className={cn(
"px-4 py-2 text-sm font-bold rounded-full transition-all",
filterTab === "library" ? "bg-white text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
)}
>
My Library
</button>
<button
data-tv-card
data-tv-card-index={2}
tabIndex={0}
onClick={() => onFilterChange("discover")}
className={cn(
"px-4 py-2 text-sm font-bold rounded-full transition-all",
filterTab === "discover" ? "bg-white text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
)}
>
Discover
</button>
{soulseekEnabled && (
<button
data-tv-card
data-tv-card-index={3}
tabIndex={0}
onClick={() => onFilterChange("soulseek")}
className={cn(
"px-4 py-2 text-sm font-bold rounded-full transition-all flex items-center gap-2",
filterTab === "soulseek" ? "bg-[#ecb200] text-black" : "bg-[#232323] text-white hover:bg-[#2a2a2a]"
)}
>
<Download className="w-4 h-4" />
Soulseek
</button>
)}
</div>
);
}
@@ -0,0 +1,72 @@
import Link from "next/link";
import Image from "next/image";
import { Music } from "lucide-react";
import { cn } from "@/utils/cn";
import { DiscoverResult } from "../types";
import { api } from "@/lib/api";
interface SimilarArtistsGridProps {
discoverResults: DiscoverResult[];
}
// Always proxy images through the backend for caching and mobile compatibility
const getProxiedImageUrl = (imageUrl: string | undefined): string | null => {
if (!imageUrl) return null;
return api.getCoverArtUrl(imageUrl, 300);
};
export function SimilarArtistsGrid({
discoverResults,
}: SimilarArtistsGridProps) {
const artistResults = discoverResults.filter((r) => r.type === "music");
if (artistResults.length <= 1) {
return null;
}
return (
<section>
<h2 className="text-2xl font-bold text-white mb-6">
Similar Artists
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-4" data-tv-section="search-results-artists">
{artistResults.slice(1, 7).map((result, index) => {
const artistId =
result.mbid || encodeURIComponent(result.name);
const imageUrl = getProxiedImageUrl(result.image);
return (
<Link
key={`artist-${artistId}-${index}`}
href={`/artist/${artistId}`}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
>
<div className="bg-[#121212] hover:bg-[#181818] transition-all p-4 rounded-lg group cursor-pointer">
<div className="aspect-square bg-[#181818] rounded-full mb-4 flex items-center justify-center overflow-hidden relative">
{imageUrl ? (
<Image
src={imageUrl}
alt={result.name}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 16vw"
className="object-cover group-hover:scale-110 transition-all"
unoptimized
/>
) : (
<Music className="w-12 h-12 text-gray-600" />
)}
</div>
<h3 className="text-base font-bold text-white line-clamp-1 mb-1">
{result.name}
</h3>
<p className="text-sm text-[#b3b3b3]">Artist</p>
</div>
</Link>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,146 @@
import { Music, Download, CheckCircle } from "lucide-react";
import { cn } from "@/utils/cn";
import { SoulseekResult } from "../types";
interface SoulseekSongsListProps {
soulseekResults: SoulseekResult[];
downloadingFiles: Set<string>;
onDownload: (result: SoulseekResult) => void;
}
// Helper function to format file size
export const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// Helper function to get quality badge
export const getQualityBadge = (result: SoulseekResult) => {
if (result.format === "flac") {
return (
<span className="px-2 py-1 text-xs font-semibold bg-purple-600/20 text-purple-400 rounded">
FLAC
</span>
);
}
if (result.bitrate >= 320) {
return (
<span className="px-2 py-1 text-xs font-semibold bg-green-600/20 text-green-400 rounded">
320 kbps
</span>
);
}
if (result.bitrate >= 256) {
return (
<span className="px-2 py-1 text-xs font-semibold bg-blue-600/20 text-blue-400 rounded">
256 kbps
</span>
);
}
return (
<span className="px-2 py-1 text-xs font-semibold bg-gray-600/20 text-gray-400 rounded">
{result.bitrate} kbps
</span>
);
};
// Helper function to parse filename
export const parseFilename = (
filename: string
): { artist: string; title: string } => {
const match = filename.match(/([^/\\]+)\.(?:mp3|flac|m4a|wav)$/i);
if (match) {
const nameWithoutExt = match[1];
const parts = nameWithoutExt.split(" - ");
if (parts.length >= 2) {
return {
artist: parts[0].trim(),
title: parts.slice(1).join(" - ").trim(),
};
}
return { artist: "Unknown", title: nameWithoutExt };
}
return { artist: "Unknown", title: filename };
};
export function SoulseekSongsList({
soulseekResults,
downloadingFiles,
onDownload,
}: SoulseekSongsListProps) {
if (soulseekResults.length === 0) {
return null;
}
return (
<section>
<h2 className="text-2xl font-bold text-white mb-6">Songs</h2>
<div className="space-y-2" data-tv-section="search-results-songs">
{soulseekResults.slice(0, 5).map((result, index) => {
const parsed = result.parsedArtist
? {
artist: result.parsedArtist,
title:
result.filename
.split("\\")
.pop()
?.split(" - ")
.slice(1)
.join(" - ") || result.filename,
}
: parseFilename(result.filename);
const isDownloading = downloadingFiles.has(result.filename);
return (
<div
key={`${result.username}-${result.filename}-${index}`}
className="flex items-center gap-4 p-3 rounded hover:bg-[#1a1a1a] group"
>
<div className="w-10 h-10 bg-[#181818] rounded flex items-center justify-center flex-shrink-0">
<Music className="w-5 h-5 text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-base font-bold text-white truncate">
{parsed.title}
</h4>
<p className="text-sm text-[#b3b3b3] truncate">
{parsed.artist}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{getQualityBadge(result)}
<button
data-tv-card
data-tv-card-index={index}
tabIndex={0}
onClick={() => onDownload(result)}
disabled={isDownloading}
className={cn(
"px-4 py-2 rounded-full font-bold transition-all flex items-center gap-2 text-sm",
isDownloading
? "bg-green-600/20 text-green-400 cursor-not-allowed"
: "bg-[#ecb200] text-black hover:bg-[#d4a000] hover:scale-105"
)}
>
{isDownloading ? (
<>
<CheckCircle className="w-4 h-4" />
Downloading
</>
) : (
<>
<Download className="w-4 h-4" />
Download
</>
)}
</button>
</div>
</div>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Search as SearchIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useIsTV } from "@/lib/tv-utils";
interface TVSearchInputProps {
initialQuery?: string;
onSearch: (query: string) => void;
}
export function TVSearchInput({ initialQuery = "", onSearch }: TVSearchInputProps) {
const isTV = useIsTV();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState(initialQuery);
const [isFocused, setIsFocused] = useState(false);
// Update query when initialQuery changes
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
onSearch(query.trim());
// Blur the input after search to return to D-pad navigation
inputRef.current?.blur();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// On Enter, submit the search
if (e.key === "Enter") {
handleSubmit(e);
}
// On Escape, blur the input
if (e.key === "Escape") {
inputRef.current?.blur();
}
};
// Only render this component in TV mode
if (!isTV) {
return null;
}
return (
<div className="mb-8" data-tv-section="tv-search">
<form onSubmit={handleSubmit}>
<div className="relative max-w-2xl">
<SearchIcon className="absolute left-5 top-1/2 -translate-y-1/2 w-6 h-6 text-gray-400" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="Press Enter to search..."
autoCapitalize="none"
autoCorrect="off"
autoComplete="off"
data-tv-card
data-tv-card-index={0}
tabIndex={0}
className={`
w-full h-16 pl-14 pr-6
bg-[#1a1a1a]
rounded-lg
text-xl text-white
placeholder-gray-500
transition-all
outline-none
border-2
${isFocused
? "border-[#ecb200] bg-[#242424]"
: "border-transparent hover:bg-[#242424]"
}
`}
/>
{query && (
<div className="absolute right-5 top-1/2 -translate-y-1/2 text-sm text-gray-500">
Press Enter to search
</div>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,66 @@
import Link from "next/link";
import Image from "next/image";
import { Music } from "lucide-react";
import { api } from "@/lib/api";
import { Artist, DiscoverResult } from "../types";
interface TopResultProps {
libraryArtist?: Artist;
discoveryArtist?: DiscoverResult;
}
export function TopResult({ libraryArtist, discoveryArtist }: TopResultProps) {
// Prefer library artist over discovery
if (!libraryArtist && !discoveryArtist) {
return null;
}
const isLibrary = !!libraryArtist;
// Get the display name
const name = libraryArtist?.name || discoveryArtist?.name || "";
// Get the artist ID for linking - prefer MBID for consistent URLs
const artistId = isLibrary
? libraryArtist!.mbid || libraryArtist!.id
: discoveryArtist?.mbid || encodeURIComponent(name);
// Get the image URL
const imageUrl = isLibrary
? libraryArtist?.heroUrl
: discoveryArtist?.image;
return (
<section data-tv-section="search-top-result">
<h2 className="text-2xl font-bold text-white mb-6">Top result</h2>
<Link
href={`/artist/${artistId}`}
className="bg-[#121212] hover:bg-[#181818] p-6 rounded-lg transition-all flex items-center gap-6 w-full sm:w-96"
data-tv-card
data-tv-card-index={0}
tabIndex={0}
>
<div className="relative w-24 h-24 bg-[#181818] rounded-full flex items-center justify-center overflow-hidden shrink-0">
{imageUrl ? (
<Image
src={api.getCoverArtUrl(imageUrl, 300)}
alt={name}
fill
sizes="96px"
className="object-cover"
unoptimized
/>
) : (
<Music className="w-12 h-12 text-gray-600" />
)}
</div>
<div className="flex-1">
<h3 className="text-3xl font-bold text-white mb-2">
{name}
</h3>
<p className="text-sm text-white font-bold">Artist</p>
</div>
</Link>
</section>
);
}