Playback reporting (position sync / resume-on-another-device): - player_configure_jellyfin now builds a PlaybackReporter sharing the player controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every auth path (login/restore/reauth); previously they never did. - The PlaybackReporterWrapper now shares the same Arc the controller and MPV progress loop report through, instead of a dead parallel Option. - Android position callbacks now emit throttled progress reports (30s/item), mirroring the MPV backend. Duration flash on pause: - resolveDuration() prefers the live store duration for the already-loaded track over the runTimeTicks estimate, so pausing no longer clobbers the slider's max to 0 when runTimeTicks is missing. Video leaking into audio mini player: - isVideoItem() also checks the backend PlayerMediaItem mediaType discriminator, so a video started via player_play_item (no Jellyfin `type`, mediaType "video") no longer surfaces in the audio mini player. Middle-truncation of long media names: - New truncateMiddle util applied to track/episode/card/mini-player titles so distinguishing tails (episode numbers, suffixes) stay visible. Adds regression tests for the duration and mini-player fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
7.7 KiB
Svelte
242 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
|
import { auth } from "$lib/stores/auth";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import LibraryGrid from "./LibraryGrid.svelte";
|
|
import TrackList from "./TrackList.svelte";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
|
|
interface Props {
|
|
artist: MediaItem;
|
|
}
|
|
|
|
let { artist }: Props = $props();
|
|
|
|
let albums = $state<MediaItem[]>([]);
|
|
let singles = $state<MediaItem[]>([]);
|
|
let topTracks = $state<MediaItem[]>([]);
|
|
let relatedArtists = $state<MediaItem[]>([]);
|
|
|
|
let albumsLoading = $state(true);
|
|
let singlesLoading = $state(true);
|
|
let tracksLoading = $state(true);
|
|
let artistsLoading = $state(true);
|
|
|
|
let showSingles = $state(false);
|
|
let showAppears = $state(false);
|
|
|
|
onMount(async () => {
|
|
await loadArtistContent();
|
|
});
|
|
|
|
async function loadArtistContent() {
|
|
try {
|
|
const repo = auth.getRepository();
|
|
if (!repo) return;
|
|
|
|
// Load albums
|
|
try {
|
|
const albumsResult = await repo.getItems(artist.id, {
|
|
includeItemTypes: ["MusicAlbum"],
|
|
limit: 50,
|
|
sortBy: "DateCreated",
|
|
sortOrder: "Descending"
|
|
});
|
|
albums = albumsResult.items.filter(item => item.type === "MusicAlbum");
|
|
} catch (e) {
|
|
console.warn("Failed to load albums:", e);
|
|
} finally {
|
|
albumsLoading = false;
|
|
}
|
|
|
|
// Load top tracks
|
|
try {
|
|
const tracksResult = await repo.getItems(artist.id, {
|
|
includeItemTypes: ["Audio"],
|
|
limit: 10,
|
|
sortBy: "CommunityRating",
|
|
sortOrder: "Descending"
|
|
});
|
|
topTracks = tracksResult.items.filter(item => item.type === "Audio");
|
|
} catch (e) {
|
|
console.warn("Failed to load tracks:", e);
|
|
} finally {
|
|
tracksLoading = false;
|
|
}
|
|
|
|
// Load related artists (by genre)
|
|
try {
|
|
if (artist.genres && artist.genres.length > 0) {
|
|
const relatedResult = await repo.getItems(artist.id, {
|
|
includeItemTypes: ["MusicArtist"],
|
|
genres: artist.genres.slice(0, 2),
|
|
limit: 12,
|
|
sortBy: "CommunityRating",
|
|
sortOrder: "Descending"
|
|
});
|
|
relatedArtists = relatedResult.items
|
|
.filter(item => item.id !== artist.id && item.type === "MusicArtist")
|
|
.slice(0, 6);
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to load related artists:", e);
|
|
} finally {
|
|
artistsLoading = false;
|
|
}
|
|
|
|
singlesLoading = false;
|
|
} catch (e) {
|
|
console.error("Error loading artist content:", e);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-8">
|
|
<!-- Hero Section -->
|
|
<div class="relative">
|
|
<!-- Backdrop -->
|
|
{#if artist.backdropImageTags?.[0]}
|
|
<div class="absolute inset-0 -z-10 h-96 overflow-hidden rounded-lg">
|
|
<CachedImage
|
|
itemId={artist.id}
|
|
imageType="Backdrop"
|
|
tag={artist.backdropImageTags[0]}
|
|
maxWidth={1920}
|
|
class="w-full h-full object-cover opacity-40"
|
|
/>
|
|
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-[var(--color-background)]"></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Artist Info -->
|
|
<div class="flex flex-col items-center text-center py-12">
|
|
<!-- Artist Image -->
|
|
{#if artist.primaryImageTag}
|
|
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
|
|
<CachedImage
|
|
itemId={artist.id}
|
|
imageType="Primary"
|
|
tag={artist.primaryImageTag}
|
|
maxWidth={400}
|
|
alt={artist.name}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Artist Name -->
|
|
<h1 class="text-4xl font-bold text-white mb-4">{artist.name}</h1>
|
|
|
|
<!-- Bio -->
|
|
{#if artist.overview}
|
|
<p class="text-gray-300 leading-relaxed max-w-3xl">
|
|
{artist.overview}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Albums Section -->
|
|
<div class="space-y-4">
|
|
<h2 class="text-2xl font-semibold text-white">Albums</h2>
|
|
{#if albumsLoading}
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{#each Array(6) as _}
|
|
<div class="animate-pulse">
|
|
<div class="aspect-square bg-[var(--color-surface)] rounded-lg mb-2"></div>
|
|
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mb-1"></div>
|
|
<div class="h-3 bg-[var(--color-surface)] rounded w-1/2"></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else if albums.length === 0}
|
|
<p class="text-gray-400">No albums found</p>
|
|
{:else}
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{#each albums as album (album.id)}
|
|
<a
|
|
href="/library/{album.id}"
|
|
class="group cursor-pointer"
|
|
>
|
|
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
|
|
{#if album.primaryImageTag}
|
|
<CachedImage
|
|
itemId={album.id}
|
|
imageType="Primary"
|
|
tag={album.primaryImageTag}
|
|
maxWidth={200}
|
|
alt={album.name}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
|
{truncateMiddle(album.name, 40)}
|
|
</p>
|
|
{#if album.productionYear}
|
|
<p class="text-xs text-gray-400">{album.productionYear}</p>
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Top Tracks Section -->
|
|
{#if topTracks.length > 0}
|
|
<div class="space-y-4">
|
|
<h2 class="text-2xl font-semibold text-white">Top Tracks</h2>
|
|
<TrackList
|
|
tracks={topTracks}
|
|
loading={tracksLoading}
|
|
showAlbum={true}
|
|
showArtist={false}
|
|
showDownload={false}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Related Artists Section -->
|
|
{#if relatedArtists.length > 0}
|
|
<div class="space-y-4">
|
|
<h2 class="text-2xl font-semibold text-white">Similar Artists</h2>
|
|
{#if artistsLoading}
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{#each Array(6) as _}
|
|
<div class="animate-pulse text-center">
|
|
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full mb-2 mx-auto"></div>
|
|
<div class="h-4 bg-[var(--color-surface)] rounded w-3/4 mx-auto"></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
{#each relatedArtists as relatedArtist (relatedArtist.id)}
|
|
<a
|
|
href="/library/{relatedArtist.id}"
|
|
class="group text-center"
|
|
>
|
|
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
|
|
{#if relatedArtist.primaryImageTag}
|
|
<CachedImage
|
|
itemId={relatedArtist.id}
|
|
imageType="Primary"
|
|
tag={relatedArtist.primaryImageTag}
|
|
maxWidth={200}
|
|
alt={relatedArtist.name}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
|
{relatedArtist.name}
|
|
</p>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|