🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.
Offline video playback — four separate defects, each of which alone stopped it:
DR-133 A completed download's file_path is already absolute (the worker
rewrites it on completion), but the player rooted it a second time and
handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
DR-134 The asset protocol was never enabled: no protocol-asset feature and no
assetProtocol config, so convertFileSrc produced URLs nothing answered.
Also silently defeated the cached-thumbnail path, which fails soft to
the server copy and hid it whenever the server was reachable.
DR-137 Tauri's asset protocol answers a range-less request by reading the
whole file into memory, and only advertises Accept-Ranges from inside
its range branch, so the first request never learns ranges exist.
Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
now served by a loopback HTTP server: bounded 4 MiB chunks streamed
from the file handle, every response length-delimited, and a range-less
request answered with one chunk rather than the file. Confined by a
per-session token and to the app data directory, because loopback is
shared between apps on Android.
DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the
request to that server before any I/O. A network-security-config
exempts 127.0.0.1 only; a remote server must still be HTTPS.
Downloads:
DR-135 download_item never records media_type and the reconnect resolver read
that NULL as 'audio', so a movie queued from a media card had its URL
resolved by get_audio_stream_url and completed as an audio-only
transcode. The item's own type now decides.
DR-136 Rows already downloaded that way are requeued on reconnect, since
prevention alone leaves them reading "downloaded" and still unplayable.
Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.
Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
206 lines
6.8 KiB
Svelte
206 lines
6.8 KiB
Svelte
<!-- TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039 -->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { navigateUp } from "$lib/utils/navigation";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { library, currentLibrary } from "$lib/stores/library";
|
|
import { music } from "$lib/stores/music";
|
|
import { isServerReachable } from "$lib/stores/connectivity";
|
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
|
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
|
import Carousel from "$lib/components/home/Carousel.svelte";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
|
|
interface Category {
|
|
id: string;
|
|
name: string;
|
|
icon: string;
|
|
description: string;
|
|
route: string;
|
|
}
|
|
|
|
const categories: Category[] = [
|
|
{
|
|
id: "tracks",
|
|
name: "Tracks",
|
|
icon: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3",
|
|
description: "All songs",
|
|
route: "/library/music/tracks",
|
|
},
|
|
{
|
|
id: "artists",
|
|
name: "Artists",
|
|
icon: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z",
|
|
description: "Browse by artist",
|
|
route: "/library/music/artists",
|
|
},
|
|
{
|
|
id: "albums",
|
|
name: "Albums",
|
|
icon: "M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z",
|
|
description: "Browse by album",
|
|
route: "/library/music/albums",
|
|
},
|
|
{
|
|
id: "genres",
|
|
name: "Genres",
|
|
icon: "M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01",
|
|
description: "Browse by genre",
|
|
route: "/library/music/genres",
|
|
},
|
|
{
|
|
id: "playlists",
|
|
name: "Playlists",
|
|
icon: "M4 6h16M4 10h16M4 14h10M14 14v6l5-3-5-3z",
|
|
description: "Your playlists",
|
|
route: "/library/music/playlists",
|
|
},
|
|
];
|
|
|
|
async function load() {
|
|
if (!$currentLibrary) {
|
|
goto("/library");
|
|
return;
|
|
}
|
|
await music.loadSections($currentLibrary.id);
|
|
}
|
|
|
|
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
|
|
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
|
|
useOfflineFilterReload(load);
|
|
|
|
onMount(async () => {
|
|
await load();
|
|
markLoaded();
|
|
});
|
|
|
|
$effect(() => {
|
|
checkServerReachability($isServerReachable);
|
|
});
|
|
|
|
function handleItemClick(item: MediaItem) {
|
|
// Albums, artists, and playlists all browse to a detail page.
|
|
goto(`/library/${item.id}`);
|
|
}
|
|
|
|
const heroItems = $derived($music.heroItems);
|
|
const recentlyPlayed = $derived($music.recentlyPlayed);
|
|
const newlyAdded = $derived($music.newlyAdded);
|
|
const playlists = $derived($music.playlists);
|
|
const rediscover = $derived($music.rediscover);
|
|
const genreRows = $derived($music.genreRows);
|
|
const isLoading = $derived($music.isLoading);
|
|
const hasContent = $derived(
|
|
heroItems.length > 0 ||
|
|
recentlyPlayed.length > 0 ||
|
|
newlyAdded.length > 0 ||
|
|
playlists.length > 0 ||
|
|
rediscover.length > 0
|
|
);
|
|
</script>
|
|
|
|
{#if isLoading}
|
|
<div class="flex justify-center items-center py-32">
|
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-8 pb-8">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between px-4">
|
|
<h1 class="text-3xl font-bold text-white">Music</h1>
|
|
<button
|
|
onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
|
|
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
|
title="Back to libraries"
|
|
aria-label="Back to libraries"
|
|
>
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Hero Banner -->
|
|
{#if heroItems.length > 0}
|
|
<HeroBanner items={heroItems} />
|
|
{/if}
|
|
|
|
<!-- Recently Listened -->
|
|
{#if recentlyPlayed.length > 0}
|
|
<Carousel
|
|
title="Recently Listened"
|
|
items={recentlyPlayed}
|
|
onItemClick={handleItemClick}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Playlists -->
|
|
{#if playlists.length > 0}
|
|
<Carousel
|
|
title="Playlists"
|
|
items={playlists}
|
|
onItemClick={handleItemClick}
|
|
showAll={() => goto("/library/music/playlists")}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Newly Added -->
|
|
{#if newlyAdded.length > 0}
|
|
<Carousel
|
|
title="Newly Added"
|
|
items={newlyAdded}
|
|
onItemClick={handleItemClick}
|
|
showAll={() => goto("/library/music/albums")}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Rediscover -->
|
|
{#if rediscover.length > 0}
|
|
<Carousel
|
|
title="Haven't listened to in a while"
|
|
items={rediscover}
|
|
onItemClick={handleItemClick}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- One slider per genre -->
|
|
{#each genreRows as row (row.id)}
|
|
<Carousel
|
|
title={row.name}
|
|
items={row.items}
|
|
onItemClick={handleItemClick}
|
|
showAll={() => goto("/library/music/genres")}
|
|
/>
|
|
{/each}
|
|
|
|
{#if !hasContent}
|
|
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
|
|
{/if}
|
|
|
|
<!-- Browse by category -->
|
|
<div class="space-y-3 px-4 pt-4">
|
|
<h2 class="text-2xl font-semibold text-white">Browse</h2>
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
{#each categories as category (category.id)}
|
|
<button
|
|
onclick={() => goto(category.route)}
|
|
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
|
|
>
|
|
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
|
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d={category.icon} />
|
|
</svg>
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="text-white font-semibold truncate">{category.name}</div>
|
|
<div class="text-gray-400 text-xs truncate">{category.description}</div>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|