Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
543 lines
18 KiB
Svelte
543 lines
18 KiB
Svelte
<!-- TRACES: UR-005, UR-017, UR-028 | DR-009 -->
|
|
<script lang="ts">
|
|
/**
|
|
* MiniPlayer component - Always-visible bottom bar audio player
|
|
*
|
|
* Shows current track, playback controls, and progress for audio content.
|
|
* Automatically hides for video content (Movie/Episode).
|
|
* Supports both local and remote playback modes.
|
|
*
|
|
* @req: UR-005 - Control media playback (pause, play, skip, scrub)
|
|
* @req: DR-009 - Audio player UI (mini player)
|
|
* @req: UR-028 - Navigate to artist/album by tapping names in now playing view
|
|
* @req: UR-017 - Like or unlike audio, albums, movies, etc.
|
|
* @req: UR-010 - Control playback of Jellyfin remote sessions
|
|
*/
|
|
|
|
import { playerController } from "$lib/player";
|
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
|
import { goto } from "$app/navigation";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { commands } from "$lib/api/bindings";
|
|
import {
|
|
mergedMedia,
|
|
mergedIsPlaying,
|
|
mergedPosition,
|
|
mergedDuration,
|
|
shouldShowAudioMiniPlayer,
|
|
} from "$lib/stores/player";
|
|
import { currentQueueItem } from "$lib/stores/queue";
|
|
import { isRemoteMode } from "$lib/stores/playbackMode";
|
|
import { selectedSession } from "$lib/stores/sessions";
|
|
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
|
|
import { haptics } from "$lib/utils/haptics";
|
|
import { toast } from "$lib/stores/toast";
|
|
import Controls from "./Controls.svelte";
|
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
|
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
|
import VolumeControl from "./VolumeControl.svelte";
|
|
import CachedImage from "../common/CachedImage.svelte";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("MiniPlayer");
|
|
|
|
interface Props {
|
|
media: MediaItem | null;
|
|
isPlaying?: boolean;
|
|
position?: number;
|
|
duration?: number;
|
|
shuffle?: boolean;
|
|
repeat?: "off" | "all" | "one";
|
|
hasNext?: boolean;
|
|
hasPrevious?: boolean;
|
|
onExpand?: () => void;
|
|
onSleepTimerClick?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
let {
|
|
media,
|
|
isPlaying = false,
|
|
position = 0,
|
|
duration = 0,
|
|
shuffle = false,
|
|
repeat = "off",
|
|
hasNext = false,
|
|
hasPrevious = false,
|
|
onExpand,
|
|
onSleepTimerClick,
|
|
className = "",
|
|
}: Props = $props();
|
|
|
|
// Use merged media store for audio player display (handles both local and remote playback)
|
|
// In remote mode, this automatically uses the remote session's nowPlayingItem
|
|
const displayMedia = $derived($mergedMedia || $currentQueueItem);
|
|
const displayIsPlaying = $derived($mergedIsPlaying);
|
|
|
|
// The player's MediaItem doesn't carry favorite state, so read it from the
|
|
// local user_data cache (offline-safe — same source the optimistic toggle
|
|
// writes to). Re-runs whenever the current track changes.
|
|
let isFavorite = $state(false);
|
|
let favoriteLoadedFor = "";
|
|
$effect(() => {
|
|
const id = displayMedia?.id;
|
|
if (!id || id === favoriteLoadedFor) return;
|
|
favoriteLoadedFor = id;
|
|
isFavorite = false;
|
|
const userId = auth.getUserId();
|
|
if (!userId) return;
|
|
commands
|
|
.storageGetPlaybackProgress(userId, id)
|
|
.then((p) => {
|
|
// Guard against a race if the track changed while awaiting.
|
|
if (displayMedia?.id === id) isFavorite = p?.isFavorite ?? false;
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
const displayPosition = $derived($mergedPosition);
|
|
const displayDuration = $derived($mergedDuration);
|
|
|
|
// State machine gated visibility - only show when player is playing/paused AND media is audio
|
|
const shouldShow = $derived($shouldShowAudioMiniPlayer);
|
|
|
|
const progress = $derived(calculateProgress(displayPosition, displayDuration));
|
|
|
|
function navigateToArtist(event: MouseEvent, artistId: string) {
|
|
event.stopPropagation();
|
|
goto(`/library/${artistId}`);
|
|
}
|
|
|
|
function navigateToAlbum(event: MouseEvent) {
|
|
const currentMedia = displayMedia;
|
|
if (currentMedia?.albumId) {
|
|
event.stopPropagation();
|
|
goto(`/library/${currentMedia.albumId}`);
|
|
}
|
|
}
|
|
|
|
// Swipe gesture state
|
|
let touchStartX = $state(0);
|
|
let touchStartY = $state(0);
|
|
let touchEndX = $state(0);
|
|
let touchEndY = $state(0);
|
|
let isSwiping = $state(false);
|
|
let swipeTransform = $state(0);
|
|
|
|
// Overflow menu state
|
|
let showOverflowMenu = $state(false);
|
|
|
|
// Control handlers for Controls component
|
|
async function handlePlayPause() {
|
|
await playerController.toggle();
|
|
}
|
|
|
|
async function handlePrevious() {
|
|
await playerController.previous();
|
|
}
|
|
|
|
async function handleNext() {
|
|
await playerController.next();
|
|
}
|
|
|
|
async function handleToggleShuffle() {
|
|
await playerController.toggleShuffle();
|
|
}
|
|
|
|
async function handleCycleRepeat() {
|
|
await playerController.cycleRepeat();
|
|
}
|
|
|
|
// Scrubbing (seek) handler
|
|
async function handleSeek(e: MouseEvent) {
|
|
const target = e.currentTarget as HTMLElement;
|
|
const rect = target.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const percent = x / rect.width;
|
|
const newPosition = percent * displayDuration;
|
|
|
|
try {
|
|
await playerController.seek(newPosition);
|
|
haptics.tap();
|
|
} catch (err) {
|
|
log.error("Failed to seek:", err);
|
|
toast.show("Failed to seek", "error");
|
|
}
|
|
}
|
|
|
|
// Swipe gesture handlers
|
|
function handleTouchStart(e: TouchEvent) {
|
|
touchStartX = e.touches[0].clientX;
|
|
touchStartY = e.touches[0].clientY;
|
|
touchEndX = touchStartX;
|
|
touchEndY = touchStartY;
|
|
isSwiping = true;
|
|
}
|
|
|
|
function handleTouchMove(e: TouchEvent) {
|
|
if (!isSwiping) return;
|
|
touchEndX = e.touches[0].clientX;
|
|
touchEndY = e.touches[0].clientY;
|
|
|
|
const diffX = touchStartX - touchEndX;
|
|
const diffY = touchStartY - touchEndY;
|
|
|
|
// Only transform if horizontal swipe is dominant
|
|
if (Math.abs(diffX) > Math.abs(diffY)) {
|
|
swipeTransform = -diffX;
|
|
// Prevent default to stop scrolling
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
function handleTouchEnd() {
|
|
if (!isSwiping) return;
|
|
isSwiping = false;
|
|
|
|
const diffX = touchStartX - touchEndX;
|
|
const diffY = touchStartY - touchEndY;
|
|
const swipeThreshold = 80;
|
|
const minSwipeDistance = 20; // Minimum distance to be considered a swipe (not a tap)
|
|
|
|
// Only process if there was meaningful movement
|
|
const totalDistance = Math.sqrt(diffX * diffX + diffY * diffY);
|
|
if (totalDistance < minSwipeDistance) {
|
|
// This was a tap, not a swipe - ignore it
|
|
swipeTransform = 0;
|
|
touchStartX = 0;
|
|
touchStartY = 0;
|
|
touchEndX = 0;
|
|
touchEndY = 0;
|
|
return;
|
|
}
|
|
|
|
// Determine swipe direction
|
|
if (Math.abs(diffX) > Math.abs(diffY)) {
|
|
// Horizontal swipe
|
|
if (Math.abs(diffX) > swipeThreshold) {
|
|
if (diffX > 0) {
|
|
// Swiped left - Next track
|
|
haptics.tap();
|
|
handleNext();
|
|
toast.show("Next track", "info", 1000);
|
|
} else {
|
|
// Swiped right - Previous track
|
|
haptics.tap();
|
|
handlePrevious();
|
|
toast.show("Previous track", "info", 1000);
|
|
}
|
|
}
|
|
} else {
|
|
// Vertical swipe
|
|
if (Math.abs(diffY) > swipeThreshold && diffY > 0) {
|
|
// Swiped up - Open full player
|
|
log.debug("Swipe-up detected, expanding player");
|
|
haptics.tap();
|
|
onExpand?.();
|
|
}
|
|
}
|
|
|
|
// Reset transform
|
|
swipeTransform = 0;
|
|
touchStartX = 0;
|
|
touchStartY = 0;
|
|
touchEndX = 0;
|
|
touchEndY = 0;
|
|
}
|
|
|
|
// Overflow menu actions
|
|
function handleAddToPlaylist() {
|
|
showOverflowMenu = false;
|
|
haptics.tap();
|
|
toast.show("Add to playlist coming soon!", "info");
|
|
}
|
|
|
|
function handleGoToAlbum() {
|
|
showOverflowMenu = false;
|
|
if (displayMedia?.albumId) {
|
|
haptics.tap();
|
|
goto(`/library/${displayMedia.albumId}`);
|
|
}
|
|
}
|
|
|
|
function handleGoToArtist() {
|
|
showOverflowMenu = false;
|
|
if (displayMedia?.artistItems?.[0]?.id) {
|
|
haptics.tap();
|
|
goto(`/library/${displayMedia.artistItems[0].id}`);
|
|
}
|
|
}
|
|
|
|
function handleShare() {
|
|
showOverflowMenu = false;
|
|
haptics.tap();
|
|
toast.show("Share coming soon!", "info");
|
|
}
|
|
|
|
function handleViewQueue() {
|
|
showOverflowMenu = false;
|
|
haptics.tap();
|
|
toast.show("Queue view coming soon!", "info");
|
|
}
|
|
</script>
|
|
|
|
{#if shouldShow && displayMedia}
|
|
<div
|
|
class="{className ||
|
|
'md:fixed md:bottom-0 fixed bottom-16 left-0 right-0'} bg-[var(--color-surface)] border-t border-gray-800 z-[60]"
|
|
>
|
|
<!-- Remote Mode Indicator -->
|
|
{#if $isRemoteMode && $selectedSession}
|
|
<div
|
|
class="px-4 py-2 bg-[var(--color-jellyfin)]/20 border-b border-[var(--color-jellyfin)]/30 flex items-center gap-2"
|
|
>
|
|
<svg
|
|
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"
|
|
/>
|
|
</svg>
|
|
<span class="text-xs text-[var(--color-jellyfin)] font-medium">
|
|
Playing on {$selectedSession.deviceName}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Progress bar (clickable for scrubbing) -->
|
|
<button
|
|
onclick={handleSeek}
|
|
class="h-1 bg-gray-700 w-full cursor-pointer hover:h-2 transition-all relative group"
|
|
aria-label="Seek"
|
|
>
|
|
<div
|
|
class="h-full bg-[var(--color-jellyfin)] transition-all duration-100 pointer-events-none"
|
|
style="width: {progress}%"
|
|
></div>
|
|
<!-- Hover indicator -->
|
|
<div
|
|
class="absolute inset-0 bg-white/10 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
></div>
|
|
</button>
|
|
|
|
<div
|
|
role="region"
|
|
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'}"
|
|
>
|
|
<!-- 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">
|
|
{#if displayMedia}
|
|
<CachedImage
|
|
itemId={displayMedia.albumId || displayMedia.id}
|
|
imageType="Primary"
|
|
tag={displayMedia.imageId}
|
|
maxWidth={100}
|
|
alt={displayMedia?.name}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
{:else}
|
|
<div class="w-full h-full flex items-center justify-center text-gray-600">
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Title & Artist -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-white truncate block w-full text-left">
|
|
{truncateMiddle(displayMedia?.name, 40)}
|
|
</div>
|
|
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
|
{#if displayMedia?.artistItems?.length}
|
|
{#each displayMedia?.artistItems as artist, i}
|
|
<button
|
|
onclick={(e) => navigateToArtist(e, artist.id)}
|
|
class="hover:text-white hover:underline transition-colors"
|
|
>
|
|
{artist.name}
|
|
</button>{#if i < (displayMedia?.artistItems?.length ?? 0) - 1}<span>,</span>{/if}
|
|
{/each}
|
|
{:else if displayMedia?.artists?.length}
|
|
<span>{displayMedia?.artists.join(", ")}</span>
|
|
{/if}
|
|
{#if displayMedia?.albumId && displayMedia?.albumName}
|
|
{#if displayMedia?.artistItems?.length || displayMedia?.artists?.length}
|
|
<span class="text-gray-500">•</span>
|
|
{/if}
|
|
<button
|
|
onclick={navigateToAlbum}
|
|
class="hover:text-white hover:underline transition-colors"
|
|
>
|
|
{displayMedia?.albumName}
|
|
</button>
|
|
{:else if displayMedia?.albumName}
|
|
<span>{displayMedia?.albumName}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Like Button -->
|
|
{#if displayMedia}
|
|
<FavoriteButton itemId={displayMedia?.id ?? ""} bind:isFavorite size="sm" />
|
|
{/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>
|
|
{/if}
|
|
|
|
<!-- Click outside to close overflow menu -->
|
|
{#if showOverflowMenu}
|
|
<button
|
|
class="fixed inset-0 z-[65]"
|
|
onclick={() => (showOverflowMenu = false)}
|
|
aria-label="Close menu"
|
|
></button>
|
|
{/if}
|