feat(immersive): unify tab navigation with view mode toggle

Consolidates navigation into single bar with mode toggle:
- Split mode tabs: Lyrics, Track Info, Suggestions, Queue
- Focus mode tabs: Coverflow, Lyrics, Queue
- Mode toggle button (lotus/split-view icons)
- V key toggles view mode, 1-3 for focus tabs
This commit is contained in:
vicrodh committed 2026-02-01 08:08:51 -06:00
1 parent aea99a66d6
commit 2004a59660
2 files changed
+96 -82

No files matched your search

@@ -1,16 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Disc3, LayoutGrid, Mic2, ListMusic, Music2, Info, Radio, Maximize, Minimize, ChevronDown, X, Square, Copy, Minus } from 'lucide-svelte'; import { Disc3, Mic2, ListMusic, Music2, Info, Radio, Maximize, Minimize, ChevronDown, X, Square, Copy, Minus } from 'lucide-svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
export type ImmersiveTab = 'lyrics' | 'trackInfo' | 'suggestions' | 'queue'; export type ImmersiveTab = 'lyrics' | 'trackInfo' | 'suggestions' | 'queue';
export type DisplayMode = 'coverflow' | 'split' | 'lyrics-focus' | 'queue-focus'; export type FocusTab = 'coverflow' | 'lyrics-focus' | 'queue-focus';
export type ViewMode = 'focus' | 'split';
interface Props { interface Props {
viewMode: ViewMode;
activeTab: ImmersiveTab; activeTab: ImmersiveTab;
displayMode: DisplayMode; activeFocusTab: FocusTab;
onViewModeChange: (mode: ViewMode) => void;
onTabChange: (tab: ImmersiveTab) => void; onTabChange: (tab: ImmersiveTab) => void;
onDisplayModeChange: (mode: DisplayMode) => void; onFocusTabChange: (tab: FocusTab) => void;
onClose: () => void; onClose: () => void;
onCloseApp?: () => void; onCloseApp?: () => void;
visible?: boolean; visible?: boolean;
@@ -25,10 +28,12 @@
} }
let { let {
viewMode,
activeTab, activeTab,
displayMode, activeFocusTab,
onViewModeChange,
onTabChange, onTabChange,
onDisplayModeChange, onFocusTabChange,
onClose, onClose,
onCloseApp, onCloseApp,
visible = true, visible = true,
@@ -69,45 +74,55 @@
} }
} }
const tabs = $derived([ // Split mode tabs
const splitTabs = $derived([
{ id: 'lyrics' as const, label: $t('player.lyrics'), icon: Music2, enabled: hasLyrics }, { id: 'lyrics' as const, label: $t('player.lyrics'), icon: Music2, enabled: hasLyrics },
{ id: 'trackInfo' as const, label: $t('player.trackInfo') || 'Track Info', icon: Info, enabled: hasTrackInfo }, { id: 'trackInfo' as const, label: $t('player.trackInfo') || 'Track Info', icon: Info, enabled: hasTrackInfo },
{ id: 'suggestions' as const, label: $t('player.suggestions') || 'Suggestions', icon: Radio, enabled: hasSuggestions }, { id: 'suggestions' as const, label: $t('player.suggestions') || 'Suggestions', icon: Radio, enabled: hasSuggestions },
{ id: 'queue' as const, label: $t('player.queue') || 'Queue', icon: ListMusic, enabled: true }, { id: 'queue' as const, label: $t('player.queue') || 'Queue', icon: ListMusic, enabled: true },
].filter(tab => tab.enabled)); ].filter(tab => tab.enabled));
const displayModes: { id: DisplayMode; icon: typeof Disc3; title: string }[] = [ // Focus mode tabs
{ id: 'coverflow', icon: Disc3, title: 'Coverflow (1)' }, const focusTabs: { id: FocusTab; label: string; icon: typeof Disc3 }[] = [
{ id: 'split', icon: LayoutGrid, title: 'Split View (2)' }, { id: 'coverflow', label: 'Coverflow', icon: Disc3 },
{ id: 'lyrics-focus', icon: Mic2, title: 'Lyrics Focus (3)' }, { id: 'lyrics-focus', label: 'Lyrics', icon: Mic2 },
{ id: 'queue-focus', icon: ListMusic, title: 'Queue Focus (4)' }, { id: 'queue-focus', label: 'Queue', icon: ListMusic },
]; ];
// Only show tabs in split mode const isFocusMode = $derived(viewMode === 'focus');
const showTabs = $derived(displayMode === 'split');
</script> </script>
<header class="immersive-header" class:visible> <header class="immersive-header" class:visible>
<!-- Left: Display mode switcher --> <!-- Left: View mode toggle -->
<div class="header-left"> <div class="header-left">
<div class="mode-switcher">
{#each displayModes as mode (mode.id)}
<button <button
class="mode-btn" class="mode-toggle"
class:active={displayMode === mode.id} onclick={() => onViewModeChange(isFocusMode ? 'split' : 'focus')}
onclick={() => onDisplayModeChange(mode.id)} title={isFocusMode ? 'Switch to Split View' : 'Switch to Focus View'}
title={mode.title}
> >
<mode.icon size={18} /> <img
src={isFocusMode ? '/lotus.svg' : '/split-view.svg'}
alt={isFocusMode ? 'Focus Mode' : 'Split Mode'}
class="mode-icon"
/>
</button> </button>
{/each}
</div>
</div> </div>
<!-- Center: Tabs (only in split mode) --> <!-- Center: Tabs (change based on view mode) -->
{#if showTabs}
<nav class="tabs"> <nav class="tabs">
{#each tabs as tab (tab.id)} {#if isFocusMode}
{#each focusTabs as tab (tab.id)}
<button
class="tab"
class:active={activeFocusTab === tab.id}
onclick={() => onFocusTabChange(tab.id)}
>
<tab.icon size={16} />
<span class="tab-label">{tab.label}</span>
</button>
{/each}
{:else}
{#each splitTabs as tab (tab.id)}
<button <button
class="tab" class="tab"
class:active={activeTab === tab.id} class:active={activeTab === tab.id}
@@ -117,10 +132,8 @@
<span class="tab-label">{tab.label}</span> <span class="tab-label">{tab.label}</span>
</button> </button>
{/each} {/each}
</nav>
{:else}
<div class="tabs-placeholder"></div>
{/if} {/if}
</nav>
<!-- Right: Expandable Window Controls --> <!-- Right: Expandable Window Controls -->
<div class="header-actions"> <div class="header-actions">
@@ -211,46 +224,38 @@
} }
.header-left { .header-left {
flex: 1; flex: 0 0 auto;
min-width: 100px; min-width: 50px;
} }
.mode-switcher { .mode-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
}
.mode-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 34px; width: 42px;
height: 34px; height: 42px;
background: none; padding: 8px;
border: none; background: rgba(0, 0, 0, 0.5);
border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.65); border-radius: 12px;
cursor: pointer; cursor: pointer;
transition: all 150ms ease; transition: all 150ms ease;
} }
.mode-btn:hover { .mode-toggle:hover {
color: rgba(255, 255, 255, 0.9); background: rgba(0, 0, 0, 0.6);
background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.25);
} }
.mode-btn.active { .mode-icon {
color: var(--text-primary, white); width: 22px;
background: rgba(255, 255, 255, 0.2); height: 22px;
filter: invert(1) opacity(0.85);
transition: filter 150ms ease;
} }
.tabs-placeholder { .mode-toggle:hover .mode-icon {
flex: 1; filter: invert(1) opacity(1);
} }
.tabs { .tabs {
@@ -3,7 +3,7 @@
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import ImmersiveBackground from './ImmersiveBackground.svelte'; import ImmersiveBackground from './ImmersiveBackground.svelte';
import ImmersiveArtwork from './ImmersiveArtwork.svelte'; import ImmersiveArtwork from './ImmersiveArtwork.svelte';
import ImmersiveHeader, { type ImmersiveTab, type DisplayMode } from './ImmersiveHeader.svelte'; import ImmersiveHeader, { type ImmersiveTab, type FocusTab, type ViewMode } from './ImmersiveHeader.svelte';
import PlayerControlsCompact from './PlayerControlsCompact.svelte'; import PlayerControlsCompact from './PlayerControlsCompact.svelte';
import LyricsPanel from './panels/LyricsPanel.svelte'; import LyricsPanel from './panels/LyricsPanel.svelte';
import TrackInfoPanel from './panels/TrackInfoPanel.svelte'; import TrackInfoPanel from './panels/TrackInfoPanel.svelte';
@@ -115,8 +115,9 @@
}: Props = $props(); }: Props = $props();
// UI State // UI State
let displayMode: DisplayMode = $state('split'); let viewMode: ViewMode = $state('split');
let activeTab: ImmersiveTab = $state('lyrics'); let activeTab: ImmersiveTab = $state('lyrics');
let activeFocusTab: FocusTab = $state('coverflow');
let showUI = $state(true); let showUI = $state(true);
let hideTimeout: ReturnType<typeof setTimeout> | null = null; let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let isFullscreen = $state(false); let isFullscreen = $state(false);
@@ -210,35 +211,38 @@
onSeek(Math.min(duration, currentTime + 5)); onSeek(Math.min(duration, currentTime + 5));
} }
break; break;
// Display mode shortcuts // View mode toggle
case '1': case 'v':
displayMode = 'coverflow'; case 'V':
viewMode = viewMode === 'split' ? 'focus' : 'split';
break; break;
case '2': // Tab shortcuts (split mode)
displayMode = 'split';
break;
case '3':
displayMode = 'lyrics-focus';
break;
case '4':
displayMode = 'queue-focus';
break;
// Tab shortcuts (only in split mode)
case 'l': case 'l':
case 'L': case 'L':
if (displayMode === 'split') activeTab = 'lyrics'; if (viewMode === 'split') activeTab = 'lyrics';
break; break;
case 't': case 't':
case 'T': case 'T':
if (displayMode === 'split' && enableCredits) activeTab = 'trackInfo'; if (viewMode === 'split' && enableCredits) activeTab = 'trackInfo';
break; break;
case 's': case 's':
case 'S': case 'S':
if (displayMode === 'split' && enableSuggestions) activeTab = 'suggestions'; if (viewMode === 'split' && enableSuggestions) activeTab = 'suggestions';
break; break;
case 'q': case 'q':
case 'Q': case 'Q':
if (displayMode === 'split') activeTab = 'queue'; if (viewMode === 'split') activeTab = 'queue';
else if (viewMode === 'focus') activeFocusTab = 'queue-focus';
break;
// Focus mode tabs
case '1':
if (viewMode === 'focus') activeFocusTab = 'coverflow';
break;
case '2':
if (viewMode === 'focus') activeFocusTab = 'lyrics-focus';
break;
case '3':
if (viewMode === 'focus') activeFocusTab = 'queue-focus';
break; break;
} }
resetHideTimer(); resetHideTimer();
@@ -280,10 +284,12 @@
<!-- Header with mode switcher --> <!-- Header with mode switcher -->
<ImmersiveHeader <ImmersiveHeader
{viewMode}
{activeTab} {activeTab}
{displayMode} {activeFocusTab}
onViewModeChange={(mode) => viewMode = mode}
onTabChange={(tab) => activeTab = tab} onTabChange={(tab) => activeTab = tab}
onDisplayModeChange={(mode) => displayMode = mode} onFocusTabChange={(tab) => activeFocusTab = tab}
onClose={handleExitImmersive} onClose={handleExitImmersive}
visible={showUI} visible={showUI}
hasLyrics={true} hasLyrics={true}
@@ -296,8 +302,10 @@
onMinimize={minimizeWindow} onMinimize={minimizeWindow}
/> />
<!-- Content based on display mode --> <!-- Content based on view mode -->
{#if displayMode === 'coverflow'} {#if viewMode === 'focus'}
<!-- Focus Mode Views -->
{#if activeFocusTab === 'coverflow'}
<!-- Coverflow: Centered artwork --> <!-- Coverflow: Centered artwork -->
<CoverflowPanel <CoverflowPanel
{artwork} {artwork}
@@ -309,7 +317,7 @@
{bitDepth} {bitDepth}
{samplingRate} {samplingRate}
/> />
{:else if displayMode === 'lyrics-focus'} {:else if activeFocusTab === 'lyrics-focus'}
<!-- Lyrics Focus: Single line, large, centered --> <!-- Lyrics Focus: Single line, large, centered -->
<LyricsFocusPanel <LyricsFocusPanel
lines={lyricsLines} lines={lyricsLines}
@@ -317,7 +325,7 @@
isLoading={lyricsLoading} isLoading={lyricsLoading}
error={lyricsError} error={lyricsError}
/> />
{:else if displayMode === 'queue-focus'} {:else if activeFocusTab === 'queue-focus'}
<!-- Queue Focus: Full screen queue --> <!-- Queue Focus: Full screen queue -->
<div class="focus-panel"> <div class="focus-panel">
<div class="focus-panel-content queue-content"> <div class="focus-panel-content queue-content">
@@ -329,6 +337,7 @@
/> />
</div> </div>
</div> </div>
{/if}
{:else} {:else}
<!-- Split: Artwork + Panel side by side --> <!-- Split: Artwork + Panel side by side -->
<div class="immersive-main"> <div class="immersive-main">