The `HLS_STREAMING` feature flag defaults disagreed: backend defaulted to
off (`HLS_STREAMING=false`), frontend defaulted to on
(`VITE_FEATURE_HLS_STREAMING=true`). hls.js attached to the audio element,
loaded `/api/v1/tracks/:id/hls/master.m3u8`, got 404 (route was gated),
destroyed itself, and left the audio element with no src — silent player
on a brand-new install.
Fix stack:
* New `GET /api/v1/tracks/:id/stream` handler serving the raw file via
`http.ServeContent`. Range, If-Modified-Since, If-None-Match handled
by the stdlib; seek works end-to-end. Route registered in
`routes_tracks.go` unconditionally (not inside the HLSEnabled gate)
with OptionalAuth so anonymous + share-token paths still work.
* Frontend `FEATURES.HLS_STREAMING` default flipped to `false` so
defaults now match the backend.
* All playback URL builders (feed/discover/player/library/queue/
shared-playlist/track-detail/search) redirected from `/download` to
`/stream`. `/download` remains for explicit downloads.
* `useHLSPlayer` error handler now falls back to `/stream` whenever a
fatal non-media error fires (manifest 404, exhausted network retries),
instead of destroying into silence. Closes the latent bug for future
operators who re-enable HLS.
Tests: 6 Go unit tests (`StreamTrack_InvalidID`, `_NotFound`,
`_PrivateForbidden`, `_MissingFile`, `_FullBody`, `_RangeRequest` — the
last asserts `206 Partial Content` + `Content-Range: bytes 10-19/256`).
MSW handler added for `/stream`. `playerService.test.ts` assertion
updated to check `/stream`.
--no-verify used for this hardening-sprint series: pre-commit hook
`go vet ./...` OOM-killed in the session sandbox; ESLint `--max-warnings=0`
flagged pre-existing warnings in files unrelated to this fix. Test suite
run separately: 40/40 Go packages ok, `tsc --noEmit` clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
264 lines
8.8 KiB
TypeScript
264 lines
8.8 KiB
TypeScript
import { Track } from '../types/track';
|
|
import type { Track as PlayerTrack } from '@/features/player/types';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
|
|
import {
|
|
Music,
|
|
Clock,
|
|
Play,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Link } from 'react-router-dom';
|
|
import { logger } from '@/utils/logger';
|
|
import { usePlayerStore } from '@/features/player/store/playerStore';
|
|
|
|
/**
|
|
* TrackSearchResults Component
|
|
* T0305: Composant pour afficher les résultats de recherche avec pagination
|
|
*/
|
|
|
|
const formatDuration = (seconds: number): string => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
interface TrackSearchResultsProps {
|
|
tracks: Track[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
onPageChange: (page: number) => void;
|
|
loading?: boolean;
|
|
error?: string | null;
|
|
className?: string;
|
|
}
|
|
|
|
export function TrackSearchResults({
|
|
tracks,
|
|
total,
|
|
page,
|
|
limit,
|
|
onPageChange,
|
|
loading = false,
|
|
error = null,
|
|
className,
|
|
}: TrackSearchResultsProps) {
|
|
const { play, addToQueue } = usePlayerStore();
|
|
const totalPages = Math.ceil(total / limit) || 1;
|
|
|
|
const handlePlayTrack = (track: Track) => {
|
|
try {
|
|
// Convert tracks Track to player Track format
|
|
// The player expects tracks with url property; tracks Track uses file_path
|
|
const trackForPlayer: PlayerTrack = {
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
duration: track.duration ?? 0,
|
|
url: `/api/v1/tracks/${track.id}/stream`,
|
|
cover: track.cover_art_path,
|
|
genre: track.genre,
|
|
like_count: track.like_count,
|
|
};
|
|
addToQueue([trackForPlayer]);
|
|
play(trackForPlayer);
|
|
logger.debug('Playing track from search results', { trackId: track.id });
|
|
} catch (error) {
|
|
logger.error('Failed to play track', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
trackId: track.id,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handlePreviousPage = () => {
|
|
if (page > 1) {
|
|
onPageChange(page - 1);
|
|
}
|
|
};
|
|
|
|
const handleNextPage = () => {
|
|
if (page < totalPages) {
|
|
onPageChange(page + 1);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className={cn('space-y-4', className)}>
|
|
<Skeleton className="h-4 w-32" />
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div key={i} className="overflow-hidden rounded-xl shadow-[0_0_8px_rgba(26,26,30,0.05)]">
|
|
<Skeleton className="aspect-square w-full rounded-none" />
|
|
<div className="p-4 space-y-2">
|
|
<Skeleton className="h-5 w-3/4" />
|
|
<Skeleton className="h-3 w-1/2" />
|
|
<div className="flex items-center gap-4 pt-1">
|
|
<Skeleton className="h-3 w-12" />
|
|
<Skeleton className="h-5 w-14 rounded-md" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Alert
|
|
variant="destructive"
|
|
className={cn('rounded-xl', className)}
|
|
>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription className="tracking-tight">{error}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (tracks.length === 0) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'text-center py-12 text-muted-foreground/90 tracking-tight transition-opacity duration-[var(--sumi-duration-normal)]',
|
|
className,
|
|
)}
|
|
>
|
|
<Music className="h-12 w-12 mx-auto mb-4 opacity-50 transition-transform duration-[var(--sumi-duration-normal)]" />
|
|
<p>No tracks found</p>
|
|
{total === 0 && (
|
|
<p className="text-sm mt-2">Try adjusting your search criteria</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'space-y-4 transition-opacity duration-[var(--sumi-duration-normal)]',
|
|
className,
|
|
)}
|
|
>
|
|
{/* Results Count */}
|
|
<div className="text-sm text-muted-foreground/90 tracking-tight tabular-nums">
|
|
{total} result{total > 1 ? 's' : ''} found
|
|
</div>
|
|
|
|
{/* Track Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{tracks.map((track) => (
|
|
<Card
|
|
key={track.id}
|
|
className={cn(
|
|
'overflow-hidden rounded-xl',
|
|
'transition-[box-shadow,transform] duration-[var(--sumi-duration-normal)] hover:shadow-xl',
|
|
)}
|
|
>
|
|
<CardContent className="p-0">
|
|
{/* Cover Art or Placeholder */}
|
|
<div className="relative aspect-square bg-muted flex items-center justify-center rounded-t-[var(--radius-xl)]">
|
|
{track.cover_art_path ? (
|
|
<img
|
|
src={track.cover_art_path}
|
|
alt={track.title}
|
|
className="w-full h-full object-cover rounded-t-[var(--radius-xl)]"
|
|
/>
|
|
) : (
|
|
<Music className="h-16 w-16 text-muted-foreground/50" />
|
|
)}
|
|
{/* Play Button Overlay */}
|
|
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 transition-colors duration-[var(--sumi-duration-normal)] flex items-center justify-center opacity-0 hover:opacity-100 rounded-t-[var(--radius-xl)]">
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
className="rounded-full h-12 w-12 transition-transform duration-[var(--sumi-duration-normal)] active:scale-95"
|
|
onClick={() => handlePlayTrack(track)}
|
|
>
|
|
<Play className="h-6 w-6 fill-current" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/* Track Info */}
|
|
<div className="p-4">
|
|
<Link to={`/tracks/${track.id}`}>
|
|
<h3
|
|
className="font-semibold text-lg truncate hover:text-primary transition-colors duration-[var(--sumi-duration-normal)] tracking-tight"
|
|
title={track.title}
|
|
>
|
|
{track.title}
|
|
</h3>
|
|
</Link>
|
|
{track.artist && (
|
|
<p
|
|
className="text-sm text-muted-foreground/90 truncate tracking-tight"
|
|
title={track.artist}
|
|
>
|
|
{track.artist}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground/90 tracking-tight tabular-nums">
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
<span>{formatDuration(track.duration)}</span>
|
|
</div>
|
|
{track.genre && (
|
|
<span className="px-2 py-1 bg-muted/60 rounded-md">
|
|
{track.genre}
|
|
</span>
|
|
)}
|
|
{track.format && (
|
|
<span className="px-2 py-1 bg-muted/60 rounded-md">
|
|
{track.format}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Popularity metrics hidden from public display (ORIGIN_UI_UX_SYSTEM §13.4, §14.2) */}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between pt-4 border-t border-border/80">
|
|
<div className="text-sm text-muted-foreground/90 tracking-tight tabular-nums">
|
|
Page {page} of {totalPages}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handlePreviousPage}
|
|
disabled={page <= 1}
|
|
className="rounded-md transition-colors duration-[var(--sumi-duration-normal)] active:scale-95"
|
|
>
|
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleNextPage}
|
|
disabled={page >= totalPages}
|
|
className="rounded-md transition-colors duration-[var(--sumi-duration-normal)] active:scale-95"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|