Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
This commit is contained in:
@@ -402,6 +402,123 @@ describe("RepositoryClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlist Methods", () => {
|
||||
beforeEach(async () => {
|
||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
||||
await client.create("https://server.com", "user1", "token123", "server1");
|
||||
});
|
||||
|
||||
it("should create a playlist", async () => {
|
||||
const mockResult = { id: "playlist-001" };
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await client.createPlaylist("My Playlist", ["track1", "track2"]);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_create", {
|
||||
handle: "test-handle-123",
|
||||
name: "My Playlist",
|
||||
itemIds: ["track1", "track2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a playlist without initial items", async () => {
|
||||
const mockResult = { id: "playlist-002" };
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
|
||||
await client.createPlaylist("Empty Playlist");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_create", {
|
||||
handle: "test-handle-123",
|
||||
name: "Empty Playlist",
|
||||
itemIds: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete a playlist", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
|
||||
await client.deletePlaylist("playlist-001");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_delete", {
|
||||
handle: "test-handle-123",
|
||||
playlistId: "playlist-001",
|
||||
});
|
||||
});
|
||||
|
||||
it("should rename a playlist", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
|
||||
await client.renamePlaylist("playlist-001", "New Name");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_rename", {
|
||||
handle: "test-handle-123",
|
||||
playlistId: "playlist-001",
|
||||
name: "New Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("should get playlist items", async () => {
|
||||
const mockItems = [
|
||||
{ playlistItemId: "entry1", id: "track1", name: "Track 1", type: "Audio" },
|
||||
{ playlistItemId: "entry2", id: "track2", name: "Track 2", type: "Audio" },
|
||||
];
|
||||
(invoke as any).mockResolvedValueOnce(mockItems);
|
||||
|
||||
const items = await client.getPlaylistItems("playlist-001");
|
||||
|
||||
expect(items).toEqual(mockItems);
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_get_items", {
|
||||
handle: "test-handle-123",
|
||||
playlistId: "playlist-001",
|
||||
});
|
||||
});
|
||||
|
||||
it("should add items to a playlist", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
|
||||
await client.addToPlaylist("playlist-001", ["track3", "track4"]);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_add_items", {
|
||||
handle: "test-handle-123",
|
||||
playlistId: "playlist-001",
|
||||
itemIds: ["track3", "track4"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove items from a playlist using entry IDs", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
|
||||
await client.removeFromPlaylist("playlist-001", ["entry1", "entry2"]);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_remove_items", {
|
||||
handle: "test-handle-123",
|
||||
playlistId: "playlist-001",
|
||||
entryIds: ["entry1", "entry2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should move a playlist item", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(undefined);
|
||||
|
||||
await client.movePlaylistItem("playlist-001", "track1", 3);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("playlist_move_item", {
|
||||
handle: "test-handle-123",
|
||||
playlistId: "playlist-001",
|
||||
itemId: "track1",
|
||||
newIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error if not initialized before playlist operations", async () => {
|
||||
const newClient = new RepositoryClient();
|
||||
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized");
|
||||
await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized");
|
||||
await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should throw error if invoke fails", async () => {
|
||||
(invoke as any).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
ImageType,
|
||||
ImageOptions,
|
||||
Genre,
|
||||
PlaylistEntry,
|
||||
PlaylistCreatedResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
@@ -305,6 +307,63 @@ export class RepositoryClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Playlist Methods (via Rust) =====
|
||||
|
||||
async createPlaylist(name: string, itemIds?: string[]): Promise<PlaylistCreatedResult> {
|
||||
return invoke<PlaylistCreatedResult>("playlist_create", {
|
||||
handle: this.ensureHandle(),
|
||||
name,
|
||||
itemIds: itemIds ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async deletePlaylist(playlistId: string): Promise<void> {
|
||||
return invoke("playlist_delete", {
|
||||
handle: this.ensureHandle(),
|
||||
playlistId,
|
||||
});
|
||||
}
|
||||
|
||||
async renamePlaylist(playlistId: string, name: string): Promise<void> {
|
||||
return invoke("playlist_rename", {
|
||||
handle: this.ensureHandle(),
|
||||
playlistId,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
async getPlaylistItems(playlistId: string): Promise<PlaylistEntry[]> {
|
||||
return invoke<PlaylistEntry[]>("playlist_get_items", {
|
||||
handle: this.ensureHandle(),
|
||||
playlistId,
|
||||
});
|
||||
}
|
||||
|
||||
async addToPlaylist(playlistId: string, itemIds: string[]): Promise<void> {
|
||||
return invoke("playlist_add_items", {
|
||||
handle: this.ensureHandle(),
|
||||
playlistId,
|
||||
itemIds,
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise<void> {
|
||||
return invoke("playlist_remove_items", {
|
||||
handle: this.ensureHandle(),
|
||||
playlistId,
|
||||
entryIds,
|
||||
});
|
||||
}
|
||||
|
||||
async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise<void> {
|
||||
return invoke("playlist_move_item", {
|
||||
handle: this.ensureHandle(),
|
||||
playlistId,
|
||||
itemId,
|
||||
newIndex,
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Getters =====
|
||||
|
||||
get serverUrl(): string {
|
||||
|
||||
@@ -240,6 +240,15 @@ export interface Genre {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Playlist types
|
||||
export interface PlaylistEntry extends MediaItem {
|
||||
playlistItemId: string;
|
||||
}
|
||||
|
||||
export interface PlaylistCreatedResult {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GetItemsOptions {
|
||||
startIndex?: number;
|
||||
limit?: number;
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
// Touch/swipe handlers
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchEndX = e.touches[0].clientX;
|
||||
isSwiping = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { MediaItem, PlaylistEntry } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import TrackList from "./TrackList.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
|
||||
interface Props {
|
||||
playlist: MediaItem;
|
||||
}
|
||||
|
||||
let { playlist }: Props = $props();
|
||||
|
||||
let entries = $state<PlaylistEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let editingName = $state(false);
|
||||
let editName = $state("");
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
// Extract MediaItem[] from PlaylistEntry[] for TrackList
|
||||
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
|
||||
|
||||
const totalDuration = $derived(
|
||||
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
loadPlaylistItems();
|
||||
});
|
||||
|
||||
async function loadPlaylistItems() {
|
||||
loading = true;
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
entries = await repo.getPlaylistItems(playlist.id);
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to load items:", e);
|
||||
toast.error("Failed to load playlist items");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayAll() {
|
||||
if (entries.length === 0) return;
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
const trackIds = entries.map(e => e.id);
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds,
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "playlist",
|
||||
playlistId: playlist.id,
|
||||
playlistName: playlist.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to play all:", e);
|
||||
toast.error("Failed to play playlist");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShufflePlay() {
|
||||
if (entries.length === 0) return;
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const repositoryHandle = repo.getHandle();
|
||||
const trackIds = entries.map(e => e.id);
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds,
|
||||
startIndex: 0,
|
||||
shuffle: true,
|
||||
context: {
|
||||
type: "playlist",
|
||||
playlistId: playlist.id,
|
||||
playlistName: playlist.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to shuffle play:", e);
|
||||
toast.error("Failed to shuffle playlist");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
const trimmed = editName.trim();
|
||||
if (!trimmed || trimmed === playlist.name) {
|
||||
editingName = false;
|
||||
editName = playlist.name;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.renamePlaylist(playlist.id, trimmed);
|
||||
playlist.name = trimmed;
|
||||
toast.success("Playlist renamed");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to rename:", e);
|
||||
toast.error("Failed to rename playlist");
|
||||
editName = playlist.name;
|
||||
} finally {
|
||||
editingName = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.deletePlaylist(playlist.id);
|
||||
toast.success("Playlist deleted");
|
||||
goto("/library");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to delete:", e);
|
||||
toast.error("Failed to delete playlist");
|
||||
} finally {
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTrack(entry: PlaylistEntry) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]);
|
||||
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
|
||||
toast.success("Track removed");
|
||||
} catch (e) {
|
||||
console.error("[PlaylistDetail] Failed to remove track:", e);
|
||||
toast.error("Failed to remove track");
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenameKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") handleRename();
|
||||
if (e.key === "Escape") {
|
||||
editingName = false;
|
||||
editName = playlist.name;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Playlist artwork -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if playlist.primaryImageTag}
|
||||
<CachedImage
|
||||
itemId={playlist.id}
|
||||
imageType="Primary"
|
||||
tag={playlist.primaryImageTag}
|
||||
maxWidth={400}
|
||||
alt={playlist.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-gray-600" 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>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 space-y-4">
|
||||
<div>
|
||||
{#if editingName}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
onkeydown={handleRenameKeydown}
|
||||
onblur={handleRename}
|
||||
class="text-3xl font-bold bg-transparent border-b-2 border-[var(--color-jellyfin)] text-white outline-none w-full"
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="text-3xl font-bold text-white cursor-pointer hover:text-[var(--color-jellyfin)] transition-colors bg-transparent border-none p-0 text-left"
|
||||
onclick={() => { editingName = true; editName = playlist.name; }}
|
||||
title="Click to rename"
|
||||
>
|
||||
{playlist.name}
|
||||
</button>
|
||||
{/if}
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
{entries.length} track{entries.length !== 1 ? "s" : ""}
|
||||
{#if totalDuration > 0}
|
||||
· {formatDuration(totalDuration)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={handlePlayAll}
|
||||
disabled={entries.length === 0}
|
||||
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play All
|
||||
</button>
|
||||
<button
|
||||
onclick={handleShufflePlay}
|
||||
disabled={entries.length === 0}
|
||||
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||
</svg>
|
||||
Shuffle
|
||||
</button>
|
||||
<button
|
||||
onclick={() => showDeleteConfirm = true}
|
||||
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if playlist.overview}
|
||||
<p class="text-gray-300 leading-relaxed max-w-2xl">{playlist.overview}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracks -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-white">Tracks</h2>
|
||||
<TrackList
|
||||
{tracks}
|
||||
{loading}
|
||||
showArtist={true}
|
||||
showAlbum={true}
|
||||
context={{ type: "playlist", playlistId: playlist.id, playlistName: playlist.name }}
|
||||
/>
|
||||
|
||||
{#if !loading && entries.length > 0}
|
||||
<div class="space-y-1 mt-4">
|
||||
{#each entries as entry, i (entry.playlistItemId)}
|
||||
<div class="flex items-center justify-end px-4 -mt-1">
|
||||
<button
|
||||
onclick={() => handleRemoveTrack(entry)}
|
||||
class="text-gray-500 hover:text-red-400 p-1 transition-colors"
|
||||
title="Remove from playlist"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 13H5v-2h14v2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
{#if showDeleteConfirm}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
||||
onclick={() => showDeleteConfirm = false}
|
||||
onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm mx-4 space-y-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white">Delete Playlist?</h3>
|
||||
<p class="text-gray-300">
|
||||
Are you sure you want to delete "{playlist.name}"? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => showDeleteConfirm = false}
|
||||
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import DownloadButton from "./DownloadButton.svelte";
|
||||
import Portal from "$lib/components/Portal.svelte";
|
||||
import AddToPlaylistModal from "$lib/components/playlist/AddToPlaylistModal.svelte";
|
||||
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
|
||||
@@ -41,6 +42,7 @@
|
||||
let isPlayingTrack = $state<string | null>(null);
|
||||
let openMenuId = $state<string | null>(null);
|
||||
let menuPosition = $state<MenuPosition | null>(null);
|
||||
let addToPlaylistTrackId = $state<string | null>(null);
|
||||
|
||||
// Track which track is currently playing (from player store)
|
||||
const currentlyPlayingId = $derived($currentMedia?.id ?? null);
|
||||
@@ -494,10 +496,31 @@
|
||||
</svg>
|
||||
Add to Queue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
addToPlaylistTrackId = selectedTrack.id;
|
||||
closeMenu();
|
||||
}}
|
||||
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" 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>
|
||||
Add to Playlist
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Add to Playlist Modal -->
|
||||
<AddToPlaylistModal
|
||||
isOpen={addToPlaylistTrackId !== null}
|
||||
onClose={() => addToPlaylistTrackId = null}
|
||||
trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []}
|
||||
/>
|
||||
|
||||
<!-- Click outside to close menu -->
|
||||
<svelte:window onclick={closeMenu} />
|
||||
|
||||
@@ -34,6 +34,19 @@ vi.mock("./DownloadButton.svelte", () => ({
|
||||
default: vi.fn(() => ({ $$: {}, $set: vi.fn(), $on: vi.fn(), $destroy: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/library", () => ({
|
||||
library: {
|
||||
loadLibraries: vi.fn(),
|
||||
loadItems: vi.fn(),
|
||||
loadItem: vi.fn(),
|
||||
setCurrentLibrary: vi.fn(),
|
||||
},
|
||||
libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
|
||||
libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
|
||||
currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
|
||||
isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) },
|
||||
}));
|
||||
|
||||
// Now import the modules after mocks are set up
|
||||
import { render, fireEvent, waitFor } from "@testing-library/svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
@@ -75,26 +75,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Shuffle -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleShuffle?.();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
class="p-2 rounded-full transition-colors {shuffle
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
title="Shuffle"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Sleep Timer -->
|
||||
<!-- Sleep Timer (above shuffle) -->
|
||||
{#if !$sleepTimerActive}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
@@ -117,6 +98,25 @@
|
||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||
{/if}
|
||||
|
||||
<!-- Shuffle -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleShuffle?.();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
class="p-2 rounded-full transition-colors {shuffle
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
title="Shuffle"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Previous -->
|
||||
<button
|
||||
onclick={(e) => {
|
||||
|
||||
@@ -31,11 +31,9 @@
|
||||
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
|
||||
import { haptics } from "$lib/utils/haptics";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
import Controls from "./Controls.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
import VolumeControl from "./VolumeControl.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
|
||||
@@ -289,14 +287,14 @@
|
||||
|
||||
<div
|
||||
role="region"
|
||||
class="px-4 py-3 flex items-center gap-4 touch-pan-y relative"
|
||||
class="px-4 py-2 touch-pan-y relative"
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}"
|
||||
>
|
||||
<!-- Media info -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<!-- Row 1: Media info, like, cast, overflow -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Artwork -->
|
||||
<div
|
||||
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
||||
@@ -354,146 +352,127 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorite Button -->
|
||||
{#if displayMedia}
|
||||
<div class="hidden sm:block">
|
||||
<!-- Like Button -->
|
||||
{#if displayMedia}
|
||||
<FavoriteButton
|
||||
itemId={displayMedia?.id ?? ""}
|
||||
isFavorite={displayMedia?.userData?.isFavorite ?? false}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cast Button (visible on all screen sizes) -->
|
||||
<CastButton size="sm" />
|
||||
|
||||
<!-- Sleep Timer Button (always visible on larger screens) -->
|
||||
{#if !$sleepTimerActive}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSleepTimerClick?.();
|
||||
}}
|
||||
ontouchstart={(e) => e.stopPropagation()}
|
||||
ontouchmove={(e) => e.stopPropagation()}
|
||||
ontouchend={(e) => e.stopPropagation()}
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors hidden sm:block"
|
||||
title="Sleep timer"
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Sleep Timer Indicator (shows when active) -->
|
||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||
|
||||
<!-- Volume Control (Linux only) -->
|
||||
<div class="hidden sm:block">
|
||||
<VolumeControl size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="text-xs text-gray-400 hidden sm:block">
|
||||
{formatTime(displayPosition)} / {formatTime(displayDuration)}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<Controls
|
||||
isPlaying={displayIsPlaying}
|
||||
{hasPrevious}
|
||||
{hasNext}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onToggleShuffle={handleToggleShuffle}
|
||||
onCycleRepeat={handleCycleRepeat}
|
||||
{onSleepTimerClick}
|
||||
/>
|
||||
|
||||
<!-- Overflow Menu Button -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => {
|
||||
showOverflowMenu = !showOverflowMenu;
|
||||
haptics.tap();
|
||||
}}
|
||||
class="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Overflow Menu Dropdown -->
|
||||
{#if showOverflowMenu}
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 w-56 bg-[var(--color-surface)] border border-gray-700 rounded-lg shadow-2xl overflow-hidden z-[70]"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
onclick={handleViewQueue}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
View Queue
|
||||
</button>
|
||||
|
||||
{#if displayMedia?.albumId}
|
||||
<button
|
||||
onclick={handleGoToAlbum}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
Go to Album
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
<button
|
||||
onclick={handleGoToArtist}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
Go to Artist
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleAddToPlaylist}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add to Playlist
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handleShare}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cast Button -->
|
||||
<CastButton size="sm" />
|
||||
|
||||
<!-- Overflow Menu Button -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => {
|
||||
showOverflowMenu = !showOverflowMenu;
|
||||
haptics.tap();
|
||||
}}
|
||||
class="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Overflow Menu Dropdown -->
|
||||
{#if showOverflowMenu}
|
||||
<div
|
||||
class="absolute bottom-full right-0 mb-2 w-56 bg-[var(--color-surface)] border border-gray-700 rounded-lg shadow-2xl overflow-hidden z-[70]"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
onclick={handleViewQueue}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
View Queue
|
||||
</button>
|
||||
|
||||
{#if displayMedia?.albumId}
|
||||
<button
|
||||
onclick={handleGoToAlbum}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
Go to Album
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
<button
|
||||
onclick={handleGoToArtist}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
Go to Artist
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleAddToPlaylist}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add to Playlist
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handleShare}
|
||||
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Playback controls (play/pause centered) -->
|
||||
<div class="flex items-center mt-1">
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<Controls
|
||||
isPlaying={displayIsPlaying}
|
||||
{hasPrevious}
|
||||
{hasNext}
|
||||
{shuffle}
|
||||
{repeat}
|
||||
onPlayPause={handlePlayPause}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onToggleShuffle={handleToggleShuffle}
|
||||
onCycleRepeat={handleCycleRepeat}
|
||||
{onSleepTimerClick}
|
||||
/>
|
||||
|
||||
<div class="flex-1 flex items-center justify-end gap-2">
|
||||
<!-- Volume Control (desktop only) -->
|
||||
<div class="hidden sm:block">
|
||||
<VolumeControl size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- Time (desktop only) -->
|
||||
<div class="text-xs text-gray-400 hidden sm:block">
|
||||
{formatTime(displayPosition)} / {formatTime(displayDuration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,27 @@
|
||||
let { isOpen = false, onClose, mediaType }: Props = $props();
|
||||
|
||||
const timePickerItems = [
|
||||
// 5 min increments to 30
|
||||
{ value: 5, label: "5 min" },
|
||||
{ value: 10, label: "10 min" },
|
||||
{ value: 15, label: "15 min" },
|
||||
{ value: 20, label: "20 min" },
|
||||
{ value: 25, label: "25 min" },
|
||||
{ value: 30, label: "30 min" },
|
||||
// 15 min increments to 2 hrs
|
||||
{ value: 45, label: "45 min" },
|
||||
{ value: 60, label: "60 min" },
|
||||
{ value: 60, label: "1 hr" },
|
||||
{ value: 75, label: "1 hr 15 min" },
|
||||
{ value: 90, label: "1 hr 30 min" },
|
||||
{ value: 105, label: "1 hr 45 min" },
|
||||
{ value: 120, label: "2 hr" },
|
||||
// 1 hr increments after
|
||||
{ value: 180, label: "3 hr" },
|
||||
{ value: 240, label: "4 hr" },
|
||||
{ value: 300, label: "5 hr" },
|
||||
];
|
||||
|
||||
let selectedMinutes = $state(30);
|
||||
let selectedMinutes = $state(15);
|
||||
|
||||
const episodePresets = [1, 2, 3];
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { volume, isMuted } from "$lib/stores/player";
|
||||
import { volume, isMuted, mergedVolume } from "$lib/stores/player";
|
||||
import { isRemoteMode } from "$lib/stores/playbackMode";
|
||||
import { selectedSession, sessions } from "$lib/stores/sessions";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
|
||||
interface Props {
|
||||
@@ -13,22 +15,32 @@
|
||||
const isAndroid = platform() === "android";
|
||||
|
||||
let showSlider = $state(false);
|
||||
let sliderValue = $state($volume);
|
||||
let sliderValue = $state($mergedVolume);
|
||||
|
||||
// Sync slider with store value
|
||||
// Sync slider with merged volume (handles both local and remote)
|
||||
$effect(() => {
|
||||
sliderValue = $volume;
|
||||
sliderValue = $mergedVolume;
|
||||
});
|
||||
|
||||
async function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const newVolume = parseFloat(target.value);
|
||||
sliderValue = newVolume;
|
||||
await invoke("player_set_volume", { volume: newVolume });
|
||||
|
||||
if ($isRemoteMode && $selectedSession) {
|
||||
// Remote mode: send volume as 0-100 integer to remote session
|
||||
await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100));
|
||||
} else {
|
||||
await invoke("player_set_volume", { volume: newVolume });
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMute() {
|
||||
await invoke("player_toggle_mute");
|
||||
if ($isRemoteMode && $selectedSession) {
|
||||
await sessions.sendToggleMute($selectedSession.id);
|
||||
} else {
|
||||
await invoke("player_toggle_mute");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSlider() {
|
||||
|
||||
@@ -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}
|
||||
@@ -4,27 +4,27 @@
|
||||
* Handles user interactions with the next episode popup.
|
||||
* Backend manages countdown logic and autoplay decisions.
|
||||
*
|
||||
* Navigation uses goto() directly to load the next episode.
|
||||
* The player page's $effect detects the URL param change and
|
||||
* calls loadAndPlay for the new episode.
|
||||
*
|
||||
* TRACES: UR-023 | DR-047, DR-048
|
||||
*/
|
||||
|
||||
import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay";
|
||||
import { goto } from "$app/navigation";
|
||||
import { cancelAutoplayCountdown } from "$lib/api/autoplay";
|
||||
import { nextEpisode } from "$lib/stores/nextEpisode";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/** Guard against double-navigation */
|
||||
let isNavigating = false;
|
||||
|
||||
/**
|
||||
* Cleanup next episode state (called on unmount/destroy)
|
||||
*/
|
||||
export function cleanup() {
|
||||
nextEpisode.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle episode ended event
|
||||
* Backend now handles autoplay decisions via on_playback_ended()
|
||||
* This function is kept for backwards compatibility but does nothing
|
||||
*/
|
||||
export async function handleEpisodeEnded(media: any) {
|
||||
// Backend now handles this - no action needed
|
||||
// The backend will emit ShowNextEpisodePopup event
|
||||
isNavigating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,13 +36,36 @@ export async function cancelAutoPlay() {
|
||||
nextEpisode.hidePopup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the next episode via goto().
|
||||
* Uses replaceState to prevent history buildup when auto-advancing.
|
||||
*/
|
||||
function navigateToEpisode(episode: MediaItem) {
|
||||
if (isNavigating) {
|
||||
console.warn("[NextEpisode] Already navigating, skipping duplicate navigation to", episode.id);
|
||||
return;
|
||||
}
|
||||
isNavigating = true;
|
||||
console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name);
|
||||
nextEpisode.hidePopup();
|
||||
goto(`/player/${episode.id}`, { replaceState: true }).finally(() => {
|
||||
isNavigating = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually play the next episode
|
||||
* Called when user clicks "Play Now" button on next episode popup
|
||||
*
|
||||
* @param nextEpisodeItem - The next episode to play
|
||||
*/
|
||||
export async function watchNextManually(nextEpisodeItem: any) {
|
||||
await playNextEpisode(nextEpisodeItem);
|
||||
nextEpisode.hidePopup();
|
||||
export async function watchNextManually(nextEpisodeItem: MediaItem) {
|
||||
await cancelAutoplayCountdown();
|
||||
navigateToEpisode(nextEpisodeItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-play the next episode when countdown reaches 0
|
||||
* Called by playerEvents when countdown_tick event has remaining_seconds: 0
|
||||
*/
|
||||
export function autoPlayNext(nextEpisodeItem: MediaItem) {
|
||||
navigateToEpisode(nextEpisodeItem);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import { player, playbackPosition } from "$lib/stores/player";
|
||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||
import { playbackMode } from "$lib/stores/playbackMode";
|
||||
import { sleepTimer } from "$lib/stores/sleepTimer";
|
||||
import { nextEpisode } from "$lib/stores/nextEpisode";
|
||||
import { nextEpisode, nextEpisodeItem as nextEpisodeItemStore } from "$lib/stores/nextEpisode";
|
||||
import { autoPlayNext } from "$lib/services/nextEpisodeService";
|
||||
import { preloadUpcomingTracks } from "$lib/services/preload";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { get } from "svelte/store";
|
||||
@@ -320,8 +321,17 @@ function handleShowNextEpisodePopup(
|
||||
|
||||
/**
|
||||
* Handle countdown tick event.
|
||||
* When countdown reaches 0, automatically trigger playback of the next episode.
|
||||
*/
|
||||
function handleCountdownTick(remainingSeconds: number): void {
|
||||
// Update next episode store with new countdown value
|
||||
nextEpisode.updateCountdown(remainingSeconds);
|
||||
|
||||
// Auto-play when countdown reaches 0
|
||||
if (remainingSeconds === 0) {
|
||||
const episode = get(nextEpisodeItemStore);
|
||||
if (episode) {
|
||||
autoPlayNext(episode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,13 @@ export type SyncOperation =
|
||||
| "unmark_favorite"
|
||||
| "update_progress"
|
||||
| "report_playback_start"
|
||||
| "report_playback_stopped";
|
||||
| "report_playback_stopped"
|
||||
| "playlist_create"
|
||||
| "playlist_delete"
|
||||
| "playlist_rename"
|
||||
| "playlist_add_items"
|
||||
| "playlist_remove_items"
|
||||
| "playlist_reorder_item";
|
||||
|
||||
/**
|
||||
* Simplified sync service - handles offline mutation queueing
|
||||
@@ -164,6 +170,32 @@ class SyncService {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ===== Playlist sync operations =====
|
||||
|
||||
async queuePlaylistCreate(playlistId: string, name: string, itemIds: string[]): Promise<number> {
|
||||
return this.queueMutation("playlist_create", playlistId, { name, itemIds });
|
||||
}
|
||||
|
||||
async queuePlaylistDelete(playlistId: string): Promise<number> {
|
||||
return this.queueMutation("playlist_delete", playlistId);
|
||||
}
|
||||
|
||||
async queuePlaylistRename(playlistId: string, name: string): Promise<number> {
|
||||
return this.queueMutation("playlist_rename", playlistId, { name });
|
||||
}
|
||||
|
||||
async queuePlaylistAddItems(playlistId: string, itemIds: string[]): Promise<number> {
|
||||
return this.queueMutation("playlist_add_items", playlistId, { itemIds });
|
||||
}
|
||||
|
||||
async queuePlaylistRemoveItems(playlistId: string, entryIds: string[]): Promise<number> {
|
||||
return this.queueMutation("playlist_remove_items", playlistId, { entryIds });
|
||||
}
|
||||
|
||||
async queuePlaylistReorderItem(playlistId: string, itemId: string, newIndex: number): Promise<number> {
|
||||
return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync operations for the current user (called during logout)
|
||||
*
|
||||
|
||||
@@ -255,10 +255,14 @@ function createPlaybackModeStore() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor remote session for disconnection
|
||||
* Monitor remote session for disconnection with grace period.
|
||||
* Requires multiple consecutive misses before declaring disconnection
|
||||
* to tolerate transient network hiccups.
|
||||
*/
|
||||
function initializeSessionMonitoring(): void {
|
||||
// Subscribe to session changes
|
||||
let consecutiveMisses = 0;
|
||||
const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval
|
||||
|
||||
selectedSession.subscribe((session) => {
|
||||
const currentState = get({ subscribe });
|
||||
|
||||
@@ -266,14 +270,28 @@ function createPlaybackModeStore() {
|
||||
// Don't interfere during an active transfer (we intentionally clear the session)
|
||||
if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) {
|
||||
if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) {
|
||||
console.warn("[PlaybackMode] Remote session lost or disconnected");
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: "idle",
|
||||
remoteSessionId: null,
|
||||
transferError: "Remote session disconnected",
|
||||
}));
|
||||
consecutiveMisses++;
|
||||
console.warn(`[PlaybackMode] Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`);
|
||||
|
||||
if (consecutiveMisses >= DISCONNECT_THRESHOLD) {
|
||||
console.warn("[PlaybackMode] Remote session lost after sustained disconnection");
|
||||
consecutiveMisses = 0;
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: "idle",
|
||||
remoteSessionId: null,
|
||||
transferError: "Remote session disconnected",
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Session is healthy, reset counter
|
||||
if (consecutiveMisses > 0) {
|
||||
console.log("[PlaybackMode] Remote session recovered after", consecutiveMisses, "misses");
|
||||
}
|
||||
consecutiveMisses = 0;
|
||||
}
|
||||
} else {
|
||||
consecutiveMisses = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,15 +228,20 @@ export const mergedVolume = derived(
|
||||
/**
|
||||
* Should show audio miniplayer - state machine gated
|
||||
* Only true when:
|
||||
* 1. Player is in playing or paused state (not idle, loading, error)
|
||||
* 2. Current media is audio (not video: Movie or Episode)
|
||||
* 1. In remote mode with an active session playing media, OR
|
||||
* 2. Player is in playing or paused state (not idle, loading, error)
|
||||
* AND current media is audio (not video: Movie or Episode)
|
||||
*/
|
||||
export const shouldShowAudioMiniPlayer = derived(
|
||||
[player, currentMedia],
|
||||
([$player, $media]) => {
|
||||
const state = $player.state;
|
||||
[player, currentMedia, isRemoteMode, selectedSession],
|
||||
([$player, $media, $isRemote, $session]) => {
|
||||
// In remote mode, show if the remote session has a now-playing item
|
||||
if ($isRemote && $session?.nowPlayingItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only show when actively playing or paused
|
||||
// Local mode: only show when actively playing or paused
|
||||
const state = $player.state;
|
||||
if (state.kind !== "playing" && state.kind !== "paused") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,65 @@ describe("Tauri Command Parameter Names - Critical Pattern Test", () => {
|
||||
expect(Object.keys(params)).toContain("repositoryHandle");
|
||||
expect(Object.keys(params)).not.toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("playlist_create: handle, name, itemIds (NOT item_ids)", () => {
|
||||
const params = {
|
||||
handle: "handle-123",
|
||||
name: "My Playlist",
|
||||
itemIds: ["track1", "track2"],
|
||||
};
|
||||
|
||||
expect(Object.keys(params)).toContain("itemIds");
|
||||
expect(Object.keys(params)).not.toContain("item_ids");
|
||||
});
|
||||
|
||||
it("playlist_get_items: handle, playlistId (NOT playlist_id)", () => {
|
||||
const params = {
|
||||
handle: "handle-123",
|
||||
playlistId: "pl-123",
|
||||
};
|
||||
|
||||
expect(Object.keys(params)).toContain("playlistId");
|
||||
expect(Object.keys(params)).not.toContain("playlist_id");
|
||||
});
|
||||
|
||||
it("playlist_add_items: playlistId, itemIds", () => {
|
||||
const params = {
|
||||
handle: "handle-123",
|
||||
playlistId: "pl-123",
|
||||
itemIds: ["t1", "t2"],
|
||||
};
|
||||
|
||||
expect(Object.keys(params)).toContain("playlistId");
|
||||
expect(Object.keys(params)).toContain("itemIds");
|
||||
expect(Object.keys(params)).not.toContain("playlist_id");
|
||||
expect(Object.keys(params)).not.toContain("item_ids");
|
||||
});
|
||||
|
||||
it("playlist_remove_items: playlistId, entryIds (NOT entry_ids)", () => {
|
||||
const params = {
|
||||
handle: "handle-123",
|
||||
playlistId: "pl-123",
|
||||
entryIds: ["e1", "e2"],
|
||||
};
|
||||
|
||||
expect(Object.keys(params)).toContain("entryIds");
|
||||
expect(Object.keys(params)).not.toContain("entry_ids");
|
||||
});
|
||||
|
||||
it("playlist_move_item: playlistId, itemId, newIndex (NOT new_index)", () => {
|
||||
const params = {
|
||||
handle: "handle-123",
|
||||
playlistId: "pl-123",
|
||||
itemId: "track1",
|
||||
newIndex: 2,
|
||||
};
|
||||
|
||||
expect(Object.keys(params)).toContain("newIndex");
|
||||
expect(Object.keys(params)).toContain("itemId");
|
||||
expect(Object.keys(params)).not.toContain("new_index");
|
||||
expect(Object.keys(params)).not.toContain("item_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested struct fields also use camelCase (via serde rename_all)", () => {
|
||||
|
||||
Reference in New Issue
Block a user