Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user