diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7156d186..0bed814a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Settings → System → Contributors** now lists community theme authors in a **Themes** sub-section alongside the **App** contributors, pulled from the theme store so it stays current as new themes are published.
* The theme card **What's new** now shows just the latest version's notes instead of the full version history.
+### Fullscreen player — Minimal and Immersive styles
+
+**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1249](https://github.com/Psychotoxical/psysonic/pull/1249)**
+
+* **Settings → Appearance → Fullscreen player style** lets you choose **Minimal** (the current view) or **Immersive** — the earlier fullscreen player, with the artist photo/backdrop, a cover-derived accent colour, and rail or Apple-style scrolling lyrics.
+* In Immersive, **Show artist photo** and **Photo dimming** are configurable; Apple-style lyrics show the artist image as a dimmed full-screen backdrop.
+
## Changed
diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx
index 9898ccba..37848618 100644
--- a/src/app/AppShell.tsx
+++ b/src/app/AppShell.tsx
@@ -14,7 +14,7 @@ import DevNetworkModeToggle from '@/app/DevNetworkModeToggle';
import { NowPlayingDropdown } from '@/features/nowPlaying';
import QueuePanel from '@/features/queue';
import AppRoutes from './AppRoutes';
-import FullscreenPlayer from '@/features/fullscreenPlayer';
+import FullscreenPlayer, { FullscreenPlayerImmersive } from '@/features/fullscreenPlayer';
import ContextMenu from '@/features/contextMenu/components/ContextMenu';
import SongInfoModal from '@/features/playback/components/SongInfoModal';
import { DownloadFolderModal } from '@/features/offline';
@@ -114,6 +114,7 @@ export function AppShell() {
useLiveSearchRouteScope();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
+ const fullscreenPlayerStyle = useAuthStore(s => s.fullscreenPlayerStyle);
const offlineCtx = useReactiveOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
@@ -338,7 +339,9 @@ export function AppShell() {
{isMobile && !isMobilePlayer && }
{!isMobilePlayer && }
{isFullscreenOpen && (
-
+ fullscreenPlayerStyle === 'immersive'
+ ?
+ :
)}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index f14793cc..2fd762b0 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -409,6 +409,7 @@ const CONTRIBUTOR_ENTRIES = [
'Completed the tauri-specta typed-IPC cutover — CI guards for bindings freshness and full command registration (PR #1230)',
'Theme Store: per-theme changelogs shown as an expandable "What\'s new" on each card, plus installed themes with an available update pinned to the top of the list (PR #1240)',
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
+ 'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
],
},
{
diff --git a/src/features/fullscreenPlayer/components/FsArt.tsx b/src/features/fullscreenPlayer/components/FsArt.tsx
new file mode 100644
index 00000000..5187a998
--- /dev/null
+++ b/src/features/fullscreenPlayer/components/FsArt.tsx
@@ -0,0 +1,59 @@
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import { Music } from 'lucide-react';
+import { useCachedUrl } from '@/ui/CachedImage';
+
+// Album art box — crossfades layers so old art stays visible while new loads.
+// Uses 300px thumbnails (portrait fallback uses 500px separately).
+//
+// Why onLoad instead of new Image() preload:
+// React batches setLayers(add invisible) + rAF setLayers(make visible) into one
+// commit, so the browser never sees opacity:0 and the CSS transition never fires.
+// Using the DOM img's own onLoad guarantees the element was painted at opacity:0
+// before we flip it to 1.
+export const FsArt = memo(function FsArt({ fetchUrl, cacheKey }: { fetchUrl: string; cacheKey: string }) {
+ // true = show raw fetchUrl immediately as fallback while blob resolves.
+ // PlayerBar uses 128px; FS player uses 300px — different cache keys, no warm hit.
+ // Showing the URL directly avoids the multi-second blank wait.
+ const blobUrl = useCachedUrl(fetchUrl, cacheKey, true);
+
+ const [layers, setLayers] = useState>([]);
+ const counter = useRef(0);
+ const cleanupTimer = useRef | null>(null);
+
+ useEffect(() => {
+ if (!blobUrl) {
+ // New track has no cover → drop the old layer so the placeholder shows,
+ // instead of leaving the previous track's art on screen.
+ setLayers(prev => (prev.length ? [] : prev));
+ return;
+ }
+ const id = ++counter.current;
+ setLayers(prev => [...prev, { src: blobUrl, id, vis: false }]);
+ }, [blobUrl]);
+
+ const handleLoad = useCallback((id: number) => {
+ if (cleanupTimer.current) clearTimeout(cleanupTimer.current);
+ setLayers(prev => prev.map(l => ({ ...l, vis: l.id === id })));
+ cleanupTimer.current = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 400);
+ }, []);
+
+ if (layers.length === 0) {
+ return
;
+ }
+
+ return (
+ <>
+ {layers.map(l => (
+ handleLoad(l.id)}
+ alt=""
+ decoding="async"
+ />
+ ))}
+ >
+ );
+});
diff --git a/src/features/fullscreenPlayer/components/FsLyricsMenu.tsx b/src/features/fullscreenPlayer/components/FsLyricsMenu.tsx
new file mode 100644
index 00000000..b4cf48d7
--- /dev/null
+++ b/src/features/fullscreenPlayer/components/FsLyricsMenu.tsx
@@ -0,0 +1,84 @@
+import React, { memo, useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useAuthStore } from '@/store/authStore';
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ accentColor: string | null;
+ triggerRef?: React.RefObject;
+}
+
+// Lyrics settings popover — shown above the mic button.
+export const FsLyricsMenu = memo(function FsLyricsMenu({ open, onClose, accentColor, triggerRef }: Props) {
+ const { t } = useTranslation();
+ const showLyrics = useAuthStore(s => s.showFullscreenLyrics);
+ const lyricsStyle = useAuthStore(s => s.fsLyricsStyle);
+ const setLyrics = useAuthStore(s => s.setShowFullscreenLyrics);
+ const setStyle = useAuthStore(s => s.setFsLyricsStyle);
+ const panelRef = useRef(null);
+
+ // Close on click outside the panel or on Escape.
+ // Ignore clicks on the trigger button so re-clicking it toggles normally
+ // instead of outside-handler closing + click re-opening.
+ useEffect(() => {
+ if (!open) return;
+ // Capture phase + stopPropagation so Escape closes only the popover, not the
+ // whole player: the player's own Escape handler (useFsIdleFade) listens in the
+ // bubble phase, so stopping propagation here keeps it from firing too.
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ e.stopPropagation();
+ onClose();
+ };
+ const onMouse = (e: MouseEvent) => {
+ const target = e.target as Node;
+ if (panelRef.current?.contains(target)) return;
+ if (triggerRef?.current?.contains(target)) return;
+ onClose();
+ };
+ window.addEventListener('keydown', onKey, true);
+ const t = setTimeout(() => window.addEventListener('mousedown', onMouse), 0);
+ return () => {
+ clearTimeout(t);
+ window.removeEventListener('keydown', onKey, true);
+ window.removeEventListener('mousedown', onMouse);
+ };
+ }, [open, onClose, triggerRef]);
+
+ if (!open) return null;
+
+ const accent = accentColor ?? 'var(--accent)';
+
+ return (
+