feat(mini-player): custom titlebar with action icons; cover/meta/controls layout polish

- Drop the native titlebar on Windows + Linux (decorations: false in
  open_mini_player); macOS keeps the system titlebar with traffic lights.
- Add a slim 26 px custom titlebar with drag region, current track
  title, and the queue / pin / open-main / close action icons.
- Remove the bottom toolbar — those four buttons now live in the
  titlebar where they're easier to reach.
- Refresh the player layout: cover shrinks 112 → 84 px, the right
  column gets title / artist / transport in a single 84 px-tall block,
  progress bar spans the full width below.
- Add a miniPlayer i18n namespace (showQueue, hideQueue, pinOnTop,
  pinOff, openMainWindow, close, emptyQueue) localized for all 8
  supported locales — previously the tooltips were hard-coded English.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-19 13:28:28 +02:00
parent d8244e0139
commit 16491f6321
11 changed files with 248 additions and 86 deletions
+6 -1
View File
@@ -2756,6 +2756,11 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
.map(|m| m.scale_factor())
.unwrap_or(1.0);
// macOS keeps the native titlebar (traffic lights + system look).
// Windows and Linux use a custom in-page titlebar so the mini fits a
// tighter visual style across all WMs (incl. tiling).
let use_decorations = cfg!(target_os = "macos");
let mut builder = tauri::WebviewWindowBuilder::new(
&app,
"mini",
@@ -2765,7 +2770,7 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
.inner_size(340.0, 180.0)
.min_inner_size(320.0, 180.0)
.resizable(true)
.decorations(true)
.decorations(use_decorations)
.always_on_top(use_always_on_top)
.skip_taskbar(false);
+96 -66
View File
@@ -1,12 +1,14 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react';
import CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import { IS_MACOS } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
@@ -87,6 +89,7 @@ function fmt(seconds: number): string {
}
export default function MiniPlayer() {
const { t } = useTranslation();
const [state, setState] = useState<MiniSyncPayload>(() => initialSnapshot());
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(() => {
@@ -322,74 +325,100 @@ export default function MiniPlayer() {
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(track.coverArt, 300)}
cacheKey={coverArtCacheKey(track.coverArt, 300)}
alt={track.album}
/>
) : (
<div className="mini-player__art-fallback" />
)}
</div>
<div className="mini-player-shell">
{!IS_MACOS && (
<div className="mini-player__titlebar" data-tauri-drag-region>
<span className="mini-player__titlebar-title" data-tauri-drag-region>
{track?.title ?? 'Psysonic Mini'}
</span>
<button
type="button"
className={`mini-player__titlebar-btn${queueOpen ? ' mini-player__titlebar-btn--active' : ''}`}
onClick={toggleQueue}
data-tauri-drag-region="false"
data-tooltip={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
aria-label={queueOpen ? t('miniPlayer.hideQueue') : t('miniPlayer.showQueue')}
>
<ListMusic size={13} />
</button>
<button
type="button"
className={`mini-player__titlebar-btn${alwaysOnTop ? ' mini-player__titlebar-btn--active' : ''}`}
onClick={toggleOnTop}
data-tauri-drag-region="false"
data-tooltip={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
aria-label={alwaysOnTop ? t('miniPlayer.pinOff') : t('miniPlayer.pinOnTop')}
>
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
</button>
<button
type="button"
className="mini-player__titlebar-btn"
onClick={showMain}
data-tauri-drag-region="false"
data-tooltip={t('miniPlayer.openMainWindow')}
aria-label={t('miniPlayer.openMainWindow')}
>
<Maximize2 size={13} />
</button>
<button
type="button"
className="mini-player__titlebar-btn mini-player__titlebar-btn--close"
onClick={closeMini}
data-tauri-drag-region="false"
data-tooltip={t('miniPlayer.close')}
aria-label={t('miniPlayer.close')}
>
<X size={13} />
</button>
</div>
)}
<div className="mini-player__body">
<div className="mini-player__titles">
<div className="mini-player__title" title={track?.title}>
{track?.title ?? '—'}
<div className={`mini-player${queueOpen ? ' mini-player--queue-open' : ''}`}>
<div className="mini-player__art">
{track?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(track.coverArt, 300)}
cacheKey={coverArtCacheKey(track.coverArt, 300)}
alt={track.album}
/>
) : (
<div className="mini-player__art-fallback" />
)}
</div>
<div className="mini-player__body" data-tauri-drag-region="false">
<div className="mini-player__titles">
<div className="mini-player__title" title={track?.title}>
{track?.title ?? '—'}
</div>
<div className="mini-player__artist" title={track?.artist}>
{track?.artist ?? ''}
</div>
</div>
<div className="mini-player__artist" title={track?.artist}>
{track?.artist ?? ''}
<div className="mini-player__controls">
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
<SkipBack size={16} />
</button>
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')} data-tauri-drag-region="false">
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button className="mini-player__btn" onClick={() => control('next')} data-tauri-drag-region="false">
<SkipForward size={16} />
</button>
</div>
</div>
<div className="mini-player__controls">
<button className="mini-player__btn" onClick={() => control('prev')} data-tauri-drag-region="false">
<SkipBack size={16} />
</button>
<button className="mini-player__btn mini-player__btn--primary" onClick={() => control('toggle')}>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button className="mini-player__btn" onClick={() => control('next')}>
<SkipForward size={16} />
</button>
</div>
<div className="mini-player__progress">
<div className="mini-player__progress" data-tauri-drag-region="false">
<div className="mini-player__progress-time">{fmt(currentTime)}</div>
<div className="mini-player__progress-track">
<div className="mini-player__progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="mini-player__progress-time">{fmt(duration)}</div>
</div>
</div>
<div className="mini-player__toolbar">
<button
className={`mini-player__tool${queueOpen ? ' mini-player__tool--active' : ''}`}
onClick={toggleQueue}
data-tooltip={queueOpen ? 'Hide queue' : 'Show queue'}
>
<ListMusic size={13} />
</button>
<button
className={`mini-player__tool${alwaysOnTop ? ' mini-player__tool--active' : ''}`}
onClick={toggleOnTop}
data-tooltip={alwaysOnTop ? 'Pin off' : 'Pin on top'}
>
{alwaysOnTop ? <Pin size={13} /> : <PinOff size={13} />}
</button>
<button className="mini-player__tool" onClick={showMain} data-tooltip="Open main window">
<Maximize2 size={13} />
</button>
<button className="mini-player__tool" onClick={closeMini} data-tooltip="Close">
<X size={13} />
</button>
</div>
{queueOpen && (
{queueOpen && (
<div
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
onMouseMove={(e) => {
@@ -412,7 +441,7 @@ export default function MiniPlayer() {
>
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
{state.queue.length === 0 ? (
<div className="mini-queue__empty">Queue is empty</div>
<div className="mini-queue__empty">{t('miniPlayer.emptyQueue')}</div>
) : (
state.queue.map((t, i) => {
let dragStyle: React.CSSProperties = {};
@@ -483,15 +512,16 @@ export default function MiniPlayer() {
</div>
)}
{ctxMenu && (
<MiniContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
track={ctxMenu.track}
index={ctxMenu.index}
onClose={() => setCtxMenu(null)}
/>
)}
{ctxMenu && (
<MiniContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
track={ctxMenu.track}
index={ctxMenu.index}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
</div>
);
}
+9
View File
@@ -913,6 +913,15 @@ export const deTranslation = {
sourceHot: 'Wiedergabe aus dem Cache',
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
},
miniPlayer: {
showQueue: 'Warteschlange einblenden',
hideQueue: 'Warteschlange ausblenden',
pinOnTop: 'Im Vordergrund halten',
pinOff: 'Vordergrund deaktivieren',
openMainWindow: 'Hauptfenster öffnen',
close: 'Schließen',
emptyQueue: 'Die Warteschlange ist leer',
},
statistics: {
title: 'Statistiken',
recentlyPlayed: 'Zuletzt gehört',
+9
View File
@@ -915,6 +915,15 @@ export const enTranslation = {
sourceHot: 'Playing from cache',
sourceStream: 'Playing from network stream',
},
miniPlayer: {
showQueue: 'Show queue',
hideQueue: 'Hide queue',
pinOnTop: 'Pin on top',
pinOff: 'Unpin',
openMainWindow: 'Open main window',
close: 'Close',
emptyQueue: 'Queue is empty',
},
statistics: {
title: 'Statistics',
recentlyPlayed: 'Recently Played',
+9
View File
@@ -906,6 +906,15 @@ export const esTranslation = {
sourceHot: 'Reproducción desde la caché',
sourceStream: 'Reproducción desde la transmisión en red',
},
miniPlayer: {
showQueue: 'Mostrar cola',
hideQueue: 'Ocultar cola',
pinOnTop: 'Mantener encima',
pinOff: 'Desfijar',
openMainWindow: 'Abrir ventana principal',
close: 'Cerrar',
emptyQueue: 'La cola está vacía',
},
statistics: {
title: 'Estadísticas',
recentlyPlayed: 'Reproducidos Recientemente',
+9
View File
@@ -901,6 +901,15 @@ export const frTranslation = {
sourceHot: 'Lecture depuis le cache',
sourceStream: 'Lecture depuis le flux réseau',
},
miniPlayer: {
showQueue: 'Afficher la file',
hideQueue: 'Masquer la file',
pinOnTop: 'Toujours visible',
pinOff: 'Désépingler',
openMainWindow: 'Ouvrir la fenêtre principale',
close: 'Fermer',
emptyQueue: 'La file est vide',
},
statistics: {
title: 'Statistiques',
recentlyPlayed: 'Écoutés récemment',
+9
View File
@@ -900,6 +900,15 @@ export const nbTranslation = {
sourceHot: 'Spiller fra cache',
sourceStream: 'Spiller fra nettverksstrøm',
},
miniPlayer: {
showQueue: 'Vis kø',
hideQueue: 'Skjul kø',
pinOnTop: 'Hold øverst',
pinOff: 'Løsne',
openMainWindow: 'Åpne hovedvindu',
close: 'Lukk',
emptyQueue: 'Køen er tom',
},
statistics: {
title: 'Statistikk',
recentlyPlayed: 'Nylig spilt',
+9
View File
@@ -900,6 +900,15 @@ export const nlTranslation = {
sourceHot: 'Afspelen vanuit cache',
sourceStream: 'Afspelen vanuit netwerkstream',
},
miniPlayer: {
showQueue: 'Wachtrij tonen',
hideQueue: 'Wachtrij verbergen',
pinOnTop: 'Altijd boven',
pinOff: 'Losmaken',
openMainWindow: 'Hoofdvenster openen',
close: 'Sluiten',
emptyQueue: 'Wachtrij is leeg',
},
statistics: {
title: 'Statistieken',
recentlyPlayed: 'Recent afgespeeld',
+9
View File
@@ -953,6 +953,15 @@ export const ruTranslation = {
sourceHot: 'Играет из кэша',
sourceStream: 'Играет из сетевого потока',
},
miniPlayer: {
showQueue: 'Показать очередь',
hideQueue: 'Скрыть очередь',
pinOnTop: 'Поверх окон',
pinOff: 'Открепить',
openMainWindow: 'Открыть главное окно',
close: 'Закрыть',
emptyQueue: 'Очередь пуста',
},
statistics: {
title: 'Статистика',
recentlyPlayed: 'Недавно проиграно',
+9
View File
@@ -896,6 +896,15 @@ export const zhTranslation = {
sourceHot: '正在从缓存播放',
sourceStream: '正在从网络流播放',
},
miniPlayer: {
showQueue: '显示队列',
hideQueue: '隐藏队列',
pinOnTop: '置顶',
pinOff: '取消置顶',
openMainWindow: '打开主窗口',
close: '关闭',
emptyQueue: '队列为空',
},
statistics: {
title: '统计',
recentlyPlayed: '最近播放',
+74 -19
View File
@@ -9056,30 +9056,87 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
}
/* ─ Mini Player window ───────────────────────────────────────────────────── */
.mini-player {
/* ── Mini player shell ── outermost wrapper that fills the window ── */
.mini-player-shell {
width: 100%;
height: 100vh;
display: grid;
grid-template-columns: 112px 1fr;
grid-template-rows: auto auto;
gap: 10px;
padding: 12px;
display: flex;
flex-direction: column;
background: var(--bg-app);
color: var(--text-primary);
overflow: hidden;
user-select: none;
overflow: hidden;
box-sizing: border-box;
}
/* ── Custom in-page titlebar (Linux + Windows; macOS keeps the native one) ── */
.mini-player__titlebar {
flex-shrink: 0;
height: 26px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px 0 10px;
background: color-mix(in srgb, var(--bg-app) 85%, var(--bg-card) 15%);
border-bottom: 1px solid var(--border-subtle);
}
.mini-player__titlebar-title {
flex: 1;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.02em;
}
.mini-player__titlebar-btn {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.mini-player__titlebar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.mini-player__titlebar-btn--active {
color: var(--accent);
}
.mini-player__titlebar-btn--close:hover {
background: var(--danger);
color: var(--ctp-base);
}
.mini-player {
flex: 1;
display: grid;
grid-template-columns: 84px 1fr;
grid-template-rows: 84px auto;
gap: 8px 10px;
padding: 10px 12px;
overflow: hidden;
box-sizing: border-box;
min-height: 0;
}
.mini-player--queue-open {
grid-template-rows: auto auto 1fr;
grid-template-rows: 84px auto 1fr;
}
.mini-player__art {
grid-row: 1 / span 2;
grid-column: 1;
grid-row: 1;
aspect-ratio: 1;
width: 112px;
height: 112px;
width: 84px;
height: 84px;
background: var(--bg-card);
border-radius: 8px;
overflow: hidden;
@@ -9097,10 +9154,13 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
}
.mini-player__body {
grid-column: 2;
grid-row: 1;
padding: 0 2px;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: space-between;
gap: 4px;
min-width: 0;
}
@@ -9130,7 +9190,6 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 2px;
}
.mini-player__btn {
display: flex;
@@ -9160,6 +9219,8 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
}
.mini-player__progress {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: center;
gap: 6px;
@@ -9183,12 +9244,6 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
transition: width 0.25s linear;
}
.mini-player__toolbar {
grid-column: 2;
display: flex;
gap: 4px;
justify-content: flex-end;
}
.mini-queue-wrap {
grid-column: 1 / -1;