Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
153 lines
5.7 KiB
Svelte
153 lines
5.7 KiB
Svelte
<script lang="ts">
|
|
import type { MediaItem, Library } from "$lib/api/types";
|
|
import { downloads } from "$lib/stores/downloads";
|
|
import { formatDuration } from "$lib/utils/duration";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
|
|
interface Props {
|
|
items: (MediaItem | Library)[];
|
|
showProgress?: boolean;
|
|
showDownloadStatus?: boolean;
|
|
onItemClick?: (item: MediaItem | Library) => void;
|
|
}
|
|
|
|
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
|
|
|
function getDownloadInfo(itemId: string) {
|
|
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
|
}
|
|
|
|
function getImageTag(item: MediaItem | Library): string | undefined {
|
|
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
|
|
}
|
|
|
|
function getSubtitle(item: MediaItem | Library): string {
|
|
if (!("type" in item)) return "";
|
|
|
|
switch (item.type) {
|
|
case "Audio":
|
|
return item.artists?.join(", ") || item.albumName || "";
|
|
case "MusicAlbum":
|
|
return item.artistItems?.map((a) => a.name).join(", ") || "";
|
|
case "Episode":
|
|
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
|
|
case "Movie":
|
|
case "Series":
|
|
return item.productionYear?.toString() || "";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
|
|
function getProgress(item: MediaItem | Library): number {
|
|
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
|
return 0;
|
|
}
|
|
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
|
}
|
|
|
|
function getTrackNumber(item: MediaItem | Library): string {
|
|
if ("indexNumber" in item && item.indexNumber) {
|
|
return item.indexNumber.toString();
|
|
}
|
|
return "";
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-1">
|
|
{#each items as item, index (item.id)}
|
|
{@const subtitle = getSubtitle(item)}
|
|
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
|
{@const progress = getProgress(item)}
|
|
{@const trackNum = getTrackNumber(item)}
|
|
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
|
|
{@const downloadInfo = getDownloadInfo(item.id)}
|
|
{@const isDownloaded = downloadInfo?.status === "completed"}
|
|
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
|
|
|
<button
|
|
type="button"
|
|
data-grid-index={index}
|
|
onclick={() => onItemClick?.(item)}
|
|
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
|
|
>
|
|
<!-- Track number or index -->
|
|
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
|
|
{trackNum || index + 1}
|
|
</span>
|
|
|
|
<!-- Thumbnail -->
|
|
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
|
|
<CachedImage
|
|
itemId={item.id}
|
|
imageType="Primary"
|
|
tag={getImageTag(item)}
|
|
maxWidth={80}
|
|
alt={item.name}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
|
|
<!-- Play overlay on hover -->
|
|
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Progress bar -->
|
|
{#if progress > 0}
|
|
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-700">
|
|
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress}%"></div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Title & Subtitle -->
|
|
<div class="flex-1 min-w-0 text-left">
|
|
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
|
{item.name}
|
|
</p>
|
|
{#if subtitle}
|
|
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Download indicator -->
|
|
{#if showDownloadStatus && (isDownloaded || isDownloading)}
|
|
{#if isDownloaded}
|
|
<span title="Downloaded">
|
|
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-label="Downloaded">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
|
</svg>
|
|
</span>
|
|
{:else if isDownloading}
|
|
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
|
|
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
|
|
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3" class="text-blue-500" />
|
|
<circle
|
|
cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-dasharray={2 * Math.PI * 10}
|
|
stroke-dashoffset={2 * Math.PI * 10 * (1 - (downloadInfo?.progress || 0))}
|
|
stroke-linecap="round" class="text-blue-500 transition-all duration-300"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- Played indicator -->
|
|
{#if isPlayed}
|
|
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
{/if}
|
|
|
|
<!-- Duration -->
|
|
{#if duration}
|
|
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|