diff --git a/src/components/AboutPsysonicLol.tsx b/src/components/AboutPsysonicLol.tsx new file mode 100644 index 00000000..5e2c0afc --- /dev/null +++ b/src/components/AboutPsysonicLol.tsx @@ -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([]); + 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 ( + <> +
+ +
+
+ Psysonic +
+
+ {aboutVersionLabel} {appVersion} +
+
+
+ + {phase === 'hint' && !overlayOpen && ( +

+ {MSG_HINT} +

+ )} + + {overlayOpen && + createPortal( +
+ +
+
+ +
+
+

+ {MSG_CONGRATS_TITLE} +

+

+ {"We're very much looking forward to you as a developer — join us on "} + + {' and build great features!'} +

+

{MSG_CONGRATS_SIGN_OFF}

+

{MSG_CONGRATS_PS}

+
+
+
, + document.body, + )} + + ); +} diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 60488c36..3131d717 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; 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, 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 { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; @@ -582,7 +582,7 @@ export default function MiniPlayer() { data-tooltip={t('queue.gapless')} aria-label={t('queue.gapless')} > - + diff --git a/src/components/PsysonicLogo.tsx b/src/components/PsysonicLogo.tsx index 7a35f2e0..e9ad666e 100644 --- a/src/components/PsysonicLogo.tsx +++ b/src/components/PsysonicLogo.tsx @@ -3,19 +3,28 @@ import React from 'react'; interface Props { className?: string; style?: React.CSSProperties; + /** + * When multiple wordmarks exist in one document, pass a unique suffix so + * gradient ids do not collide (default remains `psysonicGrad` for all other call sites). + */ + gradientIdSuffix?: string; } -export default function PsysonicLogo({ className, style }: Props) { +export default function PsysonicLogo({ className, style, gradientIdSuffix }: Props) { + const gradId = + gradientIdSuffix != null && gradientIdSuffix !== '' + ? `psysonicGrad-${gradientIdSuffix.replace(/:/g, '')}` + : 'psysonicGrad'; return ( - + ); diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 34a0c4de..18244748 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useMemo, useEffect } from 'react'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { useOrbitStore } from '../store/orbitStore'; 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 { usePlaylistStore } from '../store/playlistStore'; import { useCachedUrl } from './CachedImage'; @@ -602,7 +602,7 @@ function QueuePanelHostOrSolo() { data-tooltip={t('queue.gapless')} aria-label={t('queue.gapless')} > - +
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6e84416d..9aede1c7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -21,6 +21,7 @@ import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, Las import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; import SettingsSubSection from '../components/SettingsSubSection'; +import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; 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)', 'ArtistCardLocal i18n: use plural-aware artists.albumCount key instead of hardcoded German', '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)', '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)', + '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)', ], }, + { + github: 'peri4ko', + since: '1.43.0', + contributions: [ + 'WebView2 idle hooks when Tauri windows are hidden — Windows GPU and compositor mitigation (PR #273)', + ], + }, ] as const; const MAINTAINERS = [ @@ -3984,17 +4001,7 @@ export default function Settings() { icon={} >
-
- Psysonic -
-
- Psysonic -
-
- {t('settings.aboutVersion')} {appVersion} -
-
-
+

{t('settings.aboutDesc')} diff --git a/src/styles/components.css b/src/styles/components.css index 4e38d38d..6d2780c9 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -3756,6 +3756,168 @@ 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 { position: relative;