First working POC
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
|
||||
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 getImageUrl(item: MediaItem | Library): string {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||
return repo.getImageUrl(item.id, "Primary", {
|
||||
maxWidth: 80,
|
||||
tag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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 formatDuration(ticks?: number): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getProgress(item: MediaItem | Library): number {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
return (item.userData.playbackPositionTicks / 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 imageUrl = getImageUrl(item)}
|
||||
{@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?.played}
|
||||
{@const downloadInfo = getDownloadInfo(item.id)}
|
||||
{@const isDownloaded = downloadInfo?.status === "completed"}
|
||||
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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}
|
||||
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" title="Downloaded">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
{: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>
|
||||
Reference in New Issue
Block a user