488 lines
16 KiB
Svelte
488 lines
16 KiB
Svelte
<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 { invoke } from "@tauri-apps/api/core";
|
|
import { goto } from "$app/navigation";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import { auth } from "$lib/stores/auth";
|
|
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 SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
|
import VolumeControl from "./VolumeControl.svelte";
|
|
import CachedImage from "../common/CachedImage.svelte";
|
|
|
|
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);
|
|
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 invoke("player_toggle");
|
|
}
|
|
|
|
async function handlePrevious() {
|
|
await invoke("player_previous");
|
|
}
|
|
|
|
async function handleNext() {
|
|
await invoke("player_next");
|
|
}
|
|
|
|
async function handleToggleShuffle() {
|
|
await invoke("player_toggle_shuffle");
|
|
}
|
|
|
|
async function handleCycleRepeat() {
|
|
await invoke("player_cycle_repeat");
|
|
}
|
|
|
|
// 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 invoke("player_seek", { position: newPosition });
|
|
haptics.tap();
|
|
} catch (err) {
|
|
console.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;
|
|
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
|
|
console.log("[MiniPlayer] 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
|
|
class="px-4 py-3 flex items-center gap-4 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">
|
|
<!-- Artwork (clickable to expand) -->
|
|
<button
|
|
onclick={onExpand}
|
|
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
|
|
aria-label="Open full player"
|
|
>
|
|
{#if displayMedia?.primaryImageTag}
|
|
<CachedImage
|
|
itemId={displayMedia.id}
|
|
imageType="Primary"
|
|
tag={displayMedia.primaryImageTag}
|
|
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}
|
|
</button>
|
|
|
|
<!-- Title & Artist -->
|
|
<div class="flex-1 min-w-0">
|
|
<button
|
|
onclick={onExpand}
|
|
class="text-sm font-medium text-white truncate block w-full text-left hover:underline"
|
|
>
|
|
{displayMedia?.name}
|
|
</button>
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Favorite Button -->
|
|
{#if displayMedia}
|
|
<div class="hidden sm:block">
|
|
<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 Indicator -->
|
|
<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}
|
|
</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}
|