Merge branch 'main' into exp/orbit

# Conflicts:
#	src/components/QueuePanel.tsx
This commit is contained in:
Psychotoxical
2026-04-24 01:06:51 +02:00
6 changed files with 360 additions and 20 deletions
+162
View File
@@ -0,0 +1,162 @@
import React, { useCallback, useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import PsysonicLogo from './PsysonicLogo';
const TAPS_TO_REVEAL_HINT = 10;
const TARGET_CLICKS_IN_WINDOW = 100;
const WINDOW_MS = 60_000;
/** Hardcoded About lol copy — intentionally not in locale files. */
const MSG_HINT =
'To become a developer, you need to click the Psysonic logo 100 times within one minute.';
const MSG_CONGRATS_TITLE = 'Congratulations.';
const MSG_CONGRATS_SIGN_OFF = 'Sincerely, your maintainers.';
const MSG_CONGRATS_PS = "PS: Don't forget to star the repo! ★";
/**
* About page brand row + Settings → System → About lol (logo taps + modal).
* Modal copy is English and hardcoded by design.
*/
export function AboutPsysonicBrandHeader({
appVersion,
aboutVersionLabel,
}: {
appVersion: string;
aboutVersionLabel: string;
}) {
const modalWordmarkGradSuffix = useId().replace(/:/g, '');
const [phase, setPhase] = useState<'idle' | 'hint' | 'done'>('idle');
const [idleTaps, setIdleTaps] = useState(0);
const [hintTimestamps, setHintTimestamps] = useState<number[]>([]);
const [overlayOpen, setOverlayOpen] = useState(false);
const onLogoClick = useCallback(() => {
if (phase === 'done') return;
if (phase === 'idle') {
setIdleTaps(prev => {
const next = prev + 1;
if (next >= TAPS_TO_REVEAL_HINT) queueMicrotask(() => setPhase('hint'));
return next;
});
return;
}
if (phase === 'hint') {
const now = Date.now();
setHintTimestamps(prev => {
const inWindow = prev.filter(t => t > now - WINDOW_MS);
const nextTimes = [...inWindow, now];
if (nextTimes.length >= TARGET_CLICKS_IN_WINDOW) {
queueMicrotask(() => {
setPhase('done');
setOverlayOpen(true);
});
}
return nextTimes;
});
}
}, [phase]);
const closeOverlay = useCallback(() => setOverlayOpen(false), []);
useEffect(() => {
if (!overlayOpen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflow;
};
}, [overlayOpen]);
return (
<>
<div className="settings-about-header">
<button
type="button"
onClick={onLogoClick}
className="about-psysonic-logo-lol-hit"
aria-label="Psysonic"
>
<img src="/logo-psysonic.png" width={52} height={52} alt="" decoding="async" style={{ borderRadius: 14, display: 'block' }} />
</button>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{aboutVersionLabel} {appVersion}
</div>
</div>
</div>
{phase === 'hint' && !overlayOpen && (
<p
style={{
fontSize: 12,
color: 'var(--text-secondary)',
marginTop: '0.5rem',
lineHeight: 1.5,
}}
>
{MSG_HINT}
</p>
)}
{overlayOpen &&
createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="about-psysonic-lol-title"
className="about-psysonic-lol-overlay"
>
<button
type="button"
className="about-psysonic-lol-close"
aria-label="Close"
onClick={closeOverlay}
>
<X size={26} strokeWidth={2.25} aria-hidden />
</button>
<div className="about-psysonic-lol-panel">
<div className="about-psysonic-lol-logo-slot">
<PsysonicLogo
gradientIdSuffix={modalWordmarkGradSuffix}
className="about-psysonic-lol-logo-mark"
style={{
height: 'clamp(3.25rem, 14vw, 5.75rem)',
width: 'auto',
maxWidth: 'min(100%, 420px)',
display: 'block',
}}
/>
</div>
<div className="about-psysonic-lol-copy">
<h2 id="about-psysonic-lol-title" className="about-psysonic-lol-title">
{MSG_CONGRATS_TITLE}
</h2>
<p className="about-psysonic-lol-lede">
{"We're very much looking forward to you as a developer — join us on "}
<button
type="button"
className="about-psysonic-lol-inline-link"
onClick={() => void openUrl('https://github.com/Psychotoxical/psysonic')}
>
GitHub
</button>
{' and build great features!'}
</p>
<p className="about-psysonic-lol-signoff">{MSG_CONGRATS_SIGN_OFF}</p>
<p className="about-psysonic-lol-ps">{MSG_CONGRATS_PS}</p>
</div>
</div>
</div>,
document.body,
)}
</>
);
}
+3 -3
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { emit, listen } from '@tauri-apps/api/event'; import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic, Volume2, VolumeX, Shuffle, Infinity as InfinityIcon, Waves, ArrowUpToLine } from 'lucide-react'; import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic, Volume2, VolumeX, Shuffle, Infinity as InfinityIcon, Waves, MoveRight } from 'lucide-react';
import CachedImage from './CachedImage'; import CachedImage from './CachedImage';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
@@ -582,7 +582,7 @@ export default function MiniPlayer() {
data-tooltip={t('queue.gapless')} data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')} aria-label={t('queue.gapless')}
> >
<InfinityIcon size={13} /> <MoveRight size={13} />
</button> </button>
<button <button
@@ -604,7 +604,7 @@ export default function MiniPlayer() {
data-tooltip={t('queue.infiniteQueue')} data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')} aria-label={t('queue.infiniteQueue')}
> >
<ArrowUpToLine size={13} /> <InfinityIcon size={13} />
</button> </button>
<span className="mini-player__toolbar-sep" aria-hidden /> <span className="mini-player__toolbar-sep" aria-hidden />
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -2,7 +2,7 @@ import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import OrbitGuestQueue from './OrbitGuestQueue'; import OrbitGuestQueue from './OrbitGuestQueue';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage'; import { useCachedUrl } from './CachedImage';
@@ -602,7 +602,7 @@ function QueuePanelHostOrSolo() {
data-tooltip={t('queue.gapless')} data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')} aria-label={t('queue.gapless')}
> >
<Infinity size={13} /> <MoveRight size={13} />
</button> </button>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button <button
@@ -654,7 +654,7 @@ function QueuePanelHostOrSolo() {
data-tooltip={t('queue.infiniteQueue')} data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')} aria-label={t('queue.infiniteQueue')}
> >
<ArrowUpToLine size={13} /> <Infinity size={13} />
</button> </button>
</div> </div>
+18 -11
View File
@@ -21,6 +21,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import SettingsSubSection from '../components/SettingsSubSection'; import SettingsSubSection from '../components/SettingsSubSection';
import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@@ -142,6 +143,13 @@ const CONTRIBUTORS = [
'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)', 'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)',
'ArtistCardLocal i18n: use plural-aware artists.albumCount key instead of hardcoded German', 'ArtistCardLocal i18n: use plural-aware artists.albumCount key instead of hardcoded German',
'NixOS / flake install guide with Cachix setup (PR #209)', 'NixOS / flake install guide with Cachix setup (PR #209)',
'Subsonic: align Rust HTTP UA with main WebView UA (PR #235)',
'UI: overlay scrollbars, resizer hit-test, and Linux mini-wheel (PR #255)',
'Server invites: magic-string paste, Navidrome admin share, add-user validation (PR #258)',
'Linux: stop Wayland GTK drag proxy and PsyDnD ghost (PR #268)',
'Sidebar: long-press drag to reorder and hide nav items (PR #269)',
'Sleep timer and delayed start via long-press on play/pause (PR #270)',
'Lucky Mix — instant queue from listening history, ratings, and AudioMuse similar tracks (PR #278)',
], ],
}, },
{ {
@@ -183,6 +191,8 @@ const CONTRIBUTORS = [
'Click-to-toggle duration / remaining time in player bar with persisted preference (PR #212)', 'Click-to-toggle duration / remaining time in player bar with persisted preference (PR #212)',
'Opt-in floating player bar with themed background, accent-colored border, rounded album art, and centered volume section (PR #216)', 'Opt-in floating player bar with themed background, accent-colored border, rounded album art, and centered volume section (PR #216)',
'Linux GPU-vendor auto-detection to configure the WebKitGTK DMA-BUF renderer (disabled on NVIDIA proprietary) (PR #217)', 'Linux GPU-vendor auto-detection to configure the WebKitGTK DMA-BUF renderer (disabled on NVIDIA proprietary) (PR #217)',
'Artist page: continue playback when starting top songs (PR #220)',
'Floating player bar: scroll-padding fix (PR #221)',
], ],
}, },
{ {
@@ -194,6 +204,13 @@ const CONTRIBUTORS = [
'ICY stream metadata & AzuraCast Now Playing support (PR #146)', 'ICY stream metadata & AzuraCast Now Playing support (PR #146)',
], ],
}, },
{
github: 'peri4ko',
since: '1.43.0',
contributions: [
'WebView2 idle hooks when Tauri windows are hidden — Windows GPU and compositor mitigation (PR #273)',
],
},
] as const; ] as const;
const MAINTAINERS = [ const MAINTAINERS = [
@@ -3984,17 +4001,7 @@ export default function Settings() {
icon={<Info size={16} />} icon={<Info size={16} />}
> >
<div className="settings-card settings-about"> <div className="settings-card settings-about">
<div className="settings-about-header"> <AboutPsysonicBrandHeader appVersion={appVersion} aboutVersionLabel={t('settings.aboutVersion')} />
<img src="/logo-psysonic.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} {appVersion}
</div>
</div>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}> <p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
{t('settings.aboutDesc')} {t('settings.aboutDesc')}
+162
View File
@@ -3756,6 +3756,168 @@
gap: var(--space-4); gap: var(--space-4);
} }
.about-psysonic-logo-lol-hit {
flex-shrink: 0;
margin: 0;
padding: 0;
border: none;
background: none;
cursor: pointer;
border-radius: 14px;
line-height: 0;
}
.about-psysonic-logo-lol-hit:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.about-psysonic-lol-close {
position: fixed;
top: max(12px, env(safe-area-inset-top, 0px));
right: max(12px, env(safe-area-inset-right, 0px));
z-index: 100001;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
border: 2px solid #e2e8f0;
border-radius: var(--radius-full);
background: #1e293b;
color: #f8fafc;
cursor: pointer;
box-shadow:
0 0 0 1px #0f172a,
0 6px 24px rgba(0, 0, 0, 0.5);
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.about-psysonic-lol-close:hover {
background: #334155;
border-color: #f8fafc;
color: #fff;
}
.about-psysonic-lol-close:focus-visible {
outline: 3px solid #38bdf8;
outline-offset: 2px;
}
.about-psysonic-lol-overlay {
position: fixed;
inset: 0;
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
box-sizing: border-box;
isolation: isolate;
background: rgba(0, 0, 0, 0.82);
}
/* Opaque panel + fixed palette so contrast holds on every app theme. */
.about-psysonic-lol-panel {
width: min(26rem, calc(100vw - 2.5rem));
max-width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
text-align: center;
padding: var(--space-5) var(--space-6) var(--space-6);
border-radius: 18px;
border: 2px solid #334155;
background: #0f172a;
color: #f8fafc;
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
--logo-color-start: #a78bfa;
--logo-color-end: #38bdf8;
}
.about-psysonic-lol-logo-slot {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-1) 0 var(--space-4);
margin: 0;
border: none;
background: transparent;
box-shadow: none;
}
.about-psysonic-lol-logo-mark {
flex-shrink: 0;
filter: drop-shadow(0 2px 16px rgba(0, 0, 0, 0.55));
}
.about-psysonic-lol-copy {
display: flex;
flex-direction: column;
gap: var(--space-3);
color: #f8fafc;
}
.about-psysonic-lol-title {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.5rem, 4.5vw, 1.9rem);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
color: #ffffff;
}
.about-psysonic-lol-lede {
margin: 0;
font-size: clamp(0.9rem, 2.4vw, 1rem);
line-height: 1.55;
color: #e2e8f0;
max-width: 24em;
margin-inline: auto;
text-wrap: balance;
}
.about-psysonic-lol-inline-link {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-weight: 700;
color: #38bdf8;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.about-psysonic-lol-inline-link:hover {
color: #7dd3fc;
}
.about-psysonic-lol-signoff {
margin: 0;
font-size: 0.875rem;
line-height: 1.45;
color: #cbd5e1;
font-style: italic;
}
.about-psysonic-lol-ps {
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
color: #94a3b8;
padding-top: var(--space-2);
border-top: 1px solid #334155;
}
/* Toggle switch */ /* Toggle switch */
.toggle-switch { .toggle-switch {
position: relative; position: relative;