First working POC
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
<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}
|
||||
Reference in New Issue
Block a user