Files
jellytau/src/lib/components/player/MiniPlayer.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
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.
2026-08-21 17:41:44 +02:00

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}