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
+117
View File
@@ -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"));
+59
View File
@@ -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 {
+9
View File
@@ -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}
&middot; {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";
+20 -20
View File
@@ -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) => {
+117 -138
View File
@@ -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];
+18 -6
View File
@@ -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}
+39 -16
View File
@@ -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);
}
+11 -1
View File
@@ -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);
}
}
}
+33 -1
View File
@@ -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)
*
+27 -9
View File
@@ -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;
}
});
}
+11 -6
View File
@@ -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;
}
+59
View File
@@ -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)", () => {
+4
View File
@@ -19,6 +19,7 @@
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte";
import PlaylistDetailView from "$lib/components/library/PlaylistDetailView.svelte";
import CrewLinks from "$lib/components/library/CrewLinks.svelte";
import GenreTags from "$lib/components/library/GenreTags.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -545,6 +546,9 @@
{:else if item.type === "MusicArtist"}
<!-- Enhanced artist detail view with discography -->
<ArtistDetailView artist={item} />
{:else if item.type === "Playlist"}
<!-- Playlist detail view with track management -->
<PlaylistDetailView playlist={item} />
{:else}
<!-- Other content in grid view -->
<LibraryGrid
@@ -1,13 +1,17 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import CreatePlaylistModal from "$lib/components/playlist/CreatePlaylistModal.svelte";
/**
* Playlist browser
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
* @req: UR-014 - Make and edit playlists
*/
let showCreateModal = $state(false);
const config = {
itemType: "Playlist" as const,
title: "Playlists",
@@ -20,4 +24,22 @@
};
</script>
<GenericMediaListPage {config} />
<div class="relative">
<!-- Floating create button -->
<button
onclick={() => showCreateModal = true}
class="fixed bottom-20 right-4 z-40 w-14 h-14 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-full shadow-lg flex items-center justify-center transition-colors"
aria-label="Create playlist"
>
<svg class="w-7 h-7 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</button>
<GenericMediaListPage {config} />
</div>
<CreatePlaylistModal
isOpen={showCreateModal}
onClose={() => showCreateModal = false}
/>
+14 -16
View File
@@ -18,7 +18,7 @@
reportPlaybackProgress,
reportPlaybackStopped,
} from "$lib/services/playbackReporting";
import { cleanup as cleanupNextEpisode, handleEpisodeEnded } from "$lib/services/nextEpisodeService";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
const itemId = $derived($page.params.id);
const queueParam = $derived($page.url.searchParams.get("queue"));
@@ -62,25 +62,12 @@
let pollInterval: ReturnType<typeof setInterval> | null = null;
let loadedItemId: string | null = null;
// Handle next episode navigation event from popup
function handlePlayNextEpisode(event: Event) {
const customEvent = event as CustomEvent<{ episode: MediaItem }>;
const episode = customEvent.detail.episode;
if (episode) {
goto(`/player/${episode.id}`);
}
}
onMount(() => {
// Start position polling (only for audio via MPV backend)
pollInterval = setInterval(updateStatus, 1000);
// Listen for next episode navigation events
window.addEventListener("playNextEpisode", handlePlayNextEpisode);
return () => {
if (pollInterval) clearInterval(pollInterval);
window.removeEventListener("playNextEpisode", handlePlayNextEpisode);
};
});
@@ -92,6 +79,7 @@
$effect(() => {
const id = itemId;
if (id && id !== loadedItemId) {
console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ")");
loadAndPlay(id);
}
});
@@ -526,10 +514,20 @@
async function handleVideoEnded() {
// Call backend to handle autoplay decision (works on both Android and Linux)
// Pass the item ID and repository handle so the backend can look up the item
// and check for next episodes. HTML5 video plays independently of the Rust
// backend queue, so the backend needs these to know what just finished.
const mediaId = currentMedia?.id ?? null;
console.log("[AutoPlay] Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId);
try {
await invoke("player_on_playback_ended");
const repo = auth.getRepository();
const repoHandle = repo.getHandle();
await invoke("player_on_playback_ended", {
itemId: mediaId,
repositoryHandle: repoHandle,
});
} catch (e) {
console.error("[VideoPlayer] Failed to handle playback ended:", e);
console.error("[AutoPlay] Failed to handle playback ended:", e);
}
}