Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s

This commit is contained in:
2026-03-01 19:47:46 +01:00
parent 3a9c126dfe
commit 09780103a7
45 changed files with 5663 additions and 3332 deletions
@@ -0,0 +1,163 @@
<script lang="ts">
import { onMount } from "svelte";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { libraries } from "$lib/stores/library";
import { toast } from "$lib/stores/toast";
import CreatePlaylistModal from "./CreatePlaylistModal.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
isOpen?: boolean;
onClose?: () => void;
trackIds: string[];
}
let { isOpen = false, onClose, trackIds }: Props = $props();
let playlists = $state<MediaItem[]>([]);
let loading = $state(true);
let adding = $state<string | null>(null);
let showCreateModal = $state(false);
$effect(() => {
if (isOpen) loadPlaylists();
});
async function loadPlaylists() {
loading = true;
try {
const repo = auth.getRepository();
// Find music library for playlist browsing
const musicLib = $libraries.find(lib => lib.collectionType === "music");
if (musicLib) {
const result = await repo.getItems(musicLib.id, { includeItemTypes: ["Playlist"], limit: 100 });
playlists = result.items;
} else {
// Try searching for playlists without a parent
const result = await repo.search("", { includeItemTypes: ["Playlist"], limit: 100 });
playlists = result.items;
}
} catch (e) {
console.error("[AddToPlaylist] Failed to load playlists:", e);
toast.error("Failed to load playlists");
} finally {
loading = false;
}
}
async function handleAddToPlaylist(playlist: MediaItem) {
adding = playlist.id;
try {
const repo = auth.getRepository();
await repo.addToPlaylist(playlist.id, trackIds);
toast.success(`Added to "${playlist.name}"`);
onClose?.();
} catch (e) {
console.error("[AddToPlaylist] Failed to add:", e);
toast.error("Failed to add to playlist");
} finally {
adding = null;
}
}
function handleNewPlaylist() {
showCreateModal = true;
}
function handleCreateModalClose() {
showCreateModal = false;
onClose?.();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm w-full mx-4 space-y-4 max-h-[70vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<h3 class="text-lg font-semibold text-white">Add to Playlist</h3>
<!-- New Playlist button -->
<button
onclick={handleNewPlaylist}
class="w-full flex items-center gap-3 p-3 bg-[var(--color-background)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
>
<div class="w-10 h-10 bg-[var(--color-jellyfin)] rounded flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</div>
<span class="text-white font-medium">New Playlist</span>
</button>
<!-- Playlist list -->
<div class="overflow-y-auto flex-1 space-y-1">
{#if loading}
{#each Array(3) as _}
<div class="animate-pulse flex items-center gap-3 p-3">
<div class="w-10 h-10 bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-700 rounded w-1/2"></div>
</div>
{/each}
{:else if playlists.length === 0}
<p class="text-gray-400 text-center py-4">No playlists yet</p>
{:else}
{#each playlists as playlist (playlist.id)}
<button
onclick={() => handleAddToPlaylist(playlist)}
disabled={adding === playlist.id}
class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50"
>
<div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden">
{#if playlist.primaryImageTag}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.primaryImageTag}
maxWidth={80}
alt={playlist.name}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-gray-700 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
</svg>
</div>
{/if}
</div>
<span class="text-white text-left truncate">
{adding === playlist.id ? "Adding..." : playlist.name}
</span>
</button>
{/each}
{/if}
</div>
<button
onclick={onClose}
class="w-full px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors text-center"
>
Cancel
</button>
</div>
</div>
{/if}
<CreatePlaylistModal
isOpen={showCreateModal}
onClose={handleCreateModalClose}
initialItemIds={trackIds}
/>
@@ -0,0 +1,92 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast";
interface Props {
isOpen?: boolean;
onClose?: () => void;
initialItemIds?: string[];
}
let { isOpen = false, onClose, initialItemIds = [] }: Props = $props();
let name = $state("");
let creating = $state(false);
async function handleCreate() {
const trimmed = name.trim();
if (!trimmed) return;
creating = true;
try {
const repo = auth.getRepository();
const result = await repo.createPlaylist(trimmed, initialItemIds.length > 0 ? initialItemIds : undefined);
toast.success(`Playlist "${trimmed}" created`);
name = "";
onClose?.();
goto(`/library/${result.id}`);
} catch (e) {
console.error("[CreatePlaylist] Failed:", e);
toast.error("Failed to create playlist");
} finally {
creating = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && name.trim()) handleCreate();
if (e.key === "Escape") onClose?.();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm w-full mx-4 space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<h3 class="text-lg font-semibold text-white">Create Playlist</h3>
<input
type="text"
bind:value={name}
onkeydown={handleKeydown}
placeholder="Playlist name"
class="w-full px-4 py-2 bg-[var(--color-background)] border border-gray-600 rounded-lg text-white placeholder-gray-500 outline-none focus:border-[var(--color-jellyfin)] transition-colors"
/>
{#if initialItemIds.length > 0}
<p class="text-sm text-gray-400">
{initialItemIds.length} track{initialItemIds.length !== 1 ? "s" : ""} will be added.
</p>
{/if}
<div class="flex gap-3 justify-end">
<button
onclick={onClose}
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={handleCreate}
disabled={!name.trim() || creating}
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</div>
{/if}