jellytau/src/lib/components/player/MiniPlayer.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}