diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json
index f7b23281..0936dfe3 100644
--- a/.dependency-cruiser-known-violations.json
+++ b/.dependency-cruiser-known-violations.json
@@ -41527,6 +41527,60 @@
}
]
},
+ {
+ "type": "cycle",
+ "from": "src/store/authDiscordBannerActions.ts",
+ "to": "src/store/authStoreTypes.ts",
+ "unresolvedTo": "./authStoreTypes",
+ "dependencyTypes": [
+ "local",
+ "type-only",
+ "import"
+ ],
+ "rule": {
+ "severity": "error",
+ "name": "no-circular"
+ },
+ "cycle": [
+ {
+ "name": "src/store/authStoreTypes.ts",
+ "dependencyTypes": [
+ "local",
+ "type-only",
+ "import"
+ ]
+ },
+ {
+ "name": "src/music-network/index.ts",
+ "dependencyTypes": [
+ "local",
+ "type-only",
+ "import"
+ ]
+ },
+ {
+ "name": "src/music-network/ui/useEnrichmentPrimary.ts",
+ "dependencyTypes": [
+ "local",
+ "export"
+ ]
+ },
+ {
+ "name": "src/store/authStore.ts",
+ "dependencyTypes": [
+ "local",
+ "import"
+ ]
+ },
+ {
+ "name": "src/store/authDiscordBannerActions.ts",
+ "dependencyTypes": [
+ "local",
+ "import"
+ ]
+ }
+ ]
+ },
{
"type": "cycle",
"from": "src/store/authDiscordSettingsActions.ts",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2d18aa7..bfc96bba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Full Bulgarian (Български) UI translation — selectable from the language picker on the Settings and Login screens.
+### Discord community banner
+
+**By [@ImAsra](https://github.com/ImAsra), PR [#1222](https://github.com/Psychotoxical/psysonic/pull/1222)**
+
+* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently.
+
+
## Changed
### Frontend restructure — feature-folder architecture and hardening
diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx
index ba0dacaf..b4e2e7af 100644
--- a/src/app/AppShell.tsx
+++ b/src/app/AppShell.tsx
@@ -57,6 +57,7 @@ import { AppShellQueueResizerSeam } from '@/app/AppShellQueueResizerSeam';
import { IS_LINUX, IS_MACOS } from '@/lib/util/platform';
import { useConnectionStatus } from '@/lib/hooks/useConnectionStatus';
import { useIdlePlayQueuePull } from '@/app/hooks/useIdlePlayQueuePull';
+import { DiscordBanner, useAccumulatedUsage } from '@/features/discordBanner';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import '@/features/playback/store/previewPlayerVolumeSync';
@@ -92,6 +93,7 @@ export function AppShell() {
usePlaybackRateOrbitSync();
useTrayMenuI18n();
useServerCapabilitiesProbe();
+ useAccumulatedUsage();
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
@@ -282,6 +284,7 @@ export function AppShell() {
)}
+
{connStatus === 'disconnected' && (
)}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index 5d77b9cd..32e0f677 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -411,6 +411,7 @@ const CONTRIBUTOR_ENTRIES = [
contributions: [
'Long-press album Play to shuffle with hold progress animation (PR #888)',
'WinGet release automation — GitHub Actions workflow that opens manifest PRs on each release (PR #1077)',
+ 'Dismissible Discord community banner (PR #1222)',
],
},
{
diff --git a/src/features/discordBanner/DiscordBanner.tsx b/src/features/discordBanner/DiscordBanner.tsx
new file mode 100644
index 00000000..431736e0
--- /dev/null
+++ b/src/features/discordBanner/DiscordBanner.tsx
@@ -0,0 +1,50 @@
+import { X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { open } from '@tauri-apps/plugin-shell';
+import { useDiscordBanner } from './useDiscordBanner';
+
+const DISCORD_INVITE = 'https://discord.gg/AMnDRErm4u';
+
+export default function DiscordBanner() {
+ const { t } = useTranslation();
+ const { visible, dismiss } = useDiscordBanner();
+
+ if (!visible) return null;
+
+ const handleJoin = (): void => {
+ void open(DISCORD_INVITE);
+ dismiss(false);
+ };
+
+ return (
+
+
+
+
{t('discordBanner.message')}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/discordBanner/index.ts b/src/features/discordBanner/index.ts
new file mode 100644
index 00000000..494ba675
--- /dev/null
+++ b/src/features/discordBanner/index.ts
@@ -0,0 +1,2 @@
+export { default as DiscordBanner } from './DiscordBanner';
+export { useAccumulatedUsage } from './useAccumulatedUsage';
diff --git a/src/features/discordBanner/useAccumulatedUsage.test.ts b/src/features/discordBanner/useAccumulatedUsage.test.ts
new file mode 100644
index 00000000..6eef145b
--- /dev/null
+++ b/src/features/discordBanner/useAccumulatedUsage.test.ts
@@ -0,0 +1,46 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useAccumulatedUsage } from './useAccumulatedUsage';
+import { useAuthStore } from '@/store/authStore';
+import { resetAuthStore } from '@/test/helpers/storeReset';
+
+describe('useAccumulatedUsage', () => {
+ beforeEach(() => {
+ resetAuthStore();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ resetAuthStore();
+ });
+
+ it('accumulates elapsed time into the auth store on each tick', () => {
+ renderHook(() => useAccumulatedUsage());
+ expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(0);
+
+ act(() => {
+ vi.advanceTimersByTime(30_000);
+ });
+ expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(30_000);
+
+ act(() => {
+ vi.advanceTimersByTime(30_000);
+ });
+ expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(60_000);
+ });
+
+ it('stops accumulating after unmount', () => {
+ const { unmount } = renderHook(() => useAccumulatedUsage());
+ act(() => {
+ vi.advanceTimersByTime(30_000);
+ });
+ const afterFirstTick = useAuthStore.getState().discordBannerAccumulatedUsageMs;
+
+ unmount();
+ act(() => {
+ vi.advanceTimersByTime(60_000);
+ });
+
+ expect(useAuthStore.getState().discordBannerAccumulatedUsageMs).toBe(afterFirstTick);
+ });
+});
diff --git a/src/features/discordBanner/useAccumulatedUsage.ts b/src/features/discordBanner/useAccumulatedUsage.ts
new file mode 100644
index 00000000..601ba7ca
--- /dev/null
+++ b/src/features/discordBanner/useAccumulatedUsage.ts
@@ -0,0 +1,32 @@
+import { useEffect, useRef } from 'react';
+import { useAuthStore } from '@/store/authStore';
+
+const TICK_MS = 30_000; // flush every 30s
+
+/**
+ * Tracks total accumulated app usage time across sessions, persisted via
+ * authStore (zustand persist → localStorage). Counts time for as long as
+ * the app process is running, regardless of window focus/visibility.
+ * Mount once near the app root (e.g. AppShell).
+ */
+export function useAccumulatedUsage(): void {
+ const lastTickRef = useRef(null);
+ const addUsageMs = useAuthStore(s => s.addDiscordBannerUsageMs);
+
+ useEffect(() => {
+ lastTickRef.current = Date.now();
+
+ const flush = (): void => {
+ const now = Date.now();
+ const delta = now - (lastTickRef.current ?? now);
+ lastTickRef.current = now;
+ addUsageMs(delta);
+ };
+
+ const interval = window.setInterval(flush, TICK_MS);
+
+ return () => {
+ window.clearInterval(interval);
+ };
+ }, [addUsageMs]);
+}
diff --git a/src/features/discordBanner/useDiscordBanner.test.ts b/src/features/discordBanner/useDiscordBanner.test.ts
new file mode 100644
index 00000000..1a0818ee
--- /dev/null
+++ b/src/features/discordBanner/useDiscordBanner.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useDiscordBanner } from './useDiscordBanner';
+import { useAuthStore } from '@/store/authStore';
+import { resetAuthStore } from '@/test/helpers/storeReset';
+
+const THRESHOLD_MS = 20 * 60 * 60 * 1000; // matches useDiscordBanner
+
+describe('useDiscordBanner', () => {
+ beforeEach(resetAuthStore);
+ afterEach(resetAuthStore);
+
+ it('stays hidden until accumulated usage reaches the threshold', () => {
+ useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS - 1 });
+ const { result } = renderHook(() => useDiscordBanner());
+ expect(result.current.visible).toBe(false);
+ });
+
+ it('becomes visible at the threshold when never dismissed', () => {
+ useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
+ const { result } = renderHook(() => useDiscordBanner());
+ expect(result.current.visible).toBe(true);
+ });
+
+ it('stays hidden when permanently dismissed even far past the threshold', () => {
+ useAuthStore.setState({
+ discordBannerAccumulatedUsageMs: THRESHOLD_MS * 2,
+ discordBannerDismissed: true,
+ });
+ const { result } = renderHook(() => useDiscordBanner());
+ expect(result.current.visible).toBe(false);
+ });
+
+ it('hides for the session on a non-permanent dismiss without persisting', () => {
+ useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
+ const { result } = renderHook(() => useDiscordBanner());
+ expect(result.current.visible).toBe(true);
+
+ act(() => result.current.dismiss(false));
+
+ expect(result.current.visible).toBe(false);
+ expect(useAuthStore.getState().discordBannerDismissed).toBe(false);
+ });
+
+ it('persists a permanent dismiss to the auth store', () => {
+ useAuthStore.setState({ discordBannerAccumulatedUsageMs: THRESHOLD_MS });
+ const { result } = renderHook(() => useDiscordBanner());
+
+ act(() => result.current.dismiss(true));
+
+ expect(result.current.visible).toBe(false);
+ expect(useAuthStore.getState().discordBannerDismissed).toBe(true);
+ });
+});
diff --git a/src/features/discordBanner/useDiscordBanner.ts b/src/features/discordBanner/useDiscordBanner.ts
new file mode 100644
index 00000000..c5d70d53
--- /dev/null
+++ b/src/features/discordBanner/useDiscordBanner.ts
@@ -0,0 +1,25 @@
+import { useState } from 'react';
+import { useAuthStore } from '@/store/authStore';
+
+const USAGE_THRESHOLD_MS = 20 * 60 * 60 * 1000; // 20 hours
+
+interface UseDiscordBannerReturn {
+ visible: boolean;
+ dismiss: (permanent: boolean) => void;
+}
+
+export function useDiscordBanner(): UseDiscordBannerReturn {
+ const dismissedPermanently = useAuthStore(s => s.discordBannerDismissed);
+ const usageMs = useAuthStore(s => s.discordBannerAccumulatedUsageMs);
+ const dismissDiscordBanner = useAuthStore(s => s.dismissDiscordBanner);
+ const [sessionDismissed, setSessionDismissed] = useState(false);
+
+ const visible = !dismissedPermanently && !sessionDismissed && usageMs >= USAGE_THRESHOLD_MS;
+
+ const dismiss = (permanent: boolean): void => {
+ if (permanent) dismissDiscordBanner();
+ setSessionDismissed(true);
+ };
+
+ return { visible, dismiss };
+}
diff --git a/src/locales/bg/discordBanner.ts b/src/locales/bg/discordBanner.ts
new file mode 100644
index 00000000..b731e125
--- /dev/null
+++ b/src/locales/bg/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Присъединете се към Psysonic в Discord',
+ message: 'Присъединете се към общността на Psysonic в Discord',
+ join: 'Присъединяване',
+ neverShow: 'Да не се показва повече',
+ close: 'Скриване',
+};
diff --git a/src/locales/bg/index.ts b/src/locales/bg/index.ts
index 91b78076..6f6872ec 100644
--- a/src/locales/bg/index.ts
+++ b/src/locales/bg/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const bgTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const bgTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/de/discordBanner.ts b/src/locales/de/discordBanner.ts
new file mode 100644
index 00000000..685f4325
--- /dev/null
+++ b/src/locales/de/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Tritt Psysonic auf Discord bei',
+ message: 'Tritt der Psysonic-Community auf Discord bei',
+ join: 'Beitreten',
+ neverShow: 'Nie wieder anzeigen',
+ close: 'Verwerfen',
+};
\ No newline at end of file
diff --git a/src/locales/de/index.ts b/src/locales/de/index.ts
index 328ae299..f2c3c368 100644
--- a/src/locales/de/index.ts
+++ b/src/locales/de/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const deTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const deTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/en/discordBanner.ts b/src/locales/en/discordBanner.ts
new file mode 100644
index 00000000..e6be6d7d
--- /dev/null
+++ b/src/locales/en/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Join Psysonic on Discord',
+ message: 'Join the Psysonic community on Discord',
+ join: 'Join',
+ neverShow: 'Never show again',
+ close: 'Dismiss',
+};
\ No newline at end of file
diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts
index 99723a5c..058ff3a5 100644
--- a/src/locales/en/index.ts
+++ b/src/locales/en/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const enTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const enTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/es/discordBanner.ts b/src/locales/es/discordBanner.ts
new file mode 100644
index 00000000..db8606f0
--- /dev/null
+++ b/src/locales/es/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Únete a Psysonic en Discord',
+ message: 'Únete a la comunidad de Psysonic en Discord',
+ join: 'Unirse',
+ neverShow: 'No volver a mostrar',
+ close: 'Descartar',
+};
\ No newline at end of file
diff --git a/src/locales/es/index.ts b/src/locales/es/index.ts
index 1760f48e..97e352ce 100644
--- a/src/locales/es/index.ts
+++ b/src/locales/es/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const esTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const esTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/fr/discordBanner.ts b/src/locales/fr/discordBanner.ts
new file mode 100644
index 00000000..fbf3a43d
--- /dev/null
+++ b/src/locales/fr/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Rejoindre Psysonic sur Discord',
+ message: 'Rejoignez la communauté Psysonic sur Discord',
+ join: 'Rejoindre',
+ neverShow: 'Ne plus afficher',
+ close: 'Ignorer',
+};
\ No newline at end of file
diff --git a/src/locales/fr/index.ts b/src/locales/fr/index.ts
index c169ef42..805a7ade 100644
--- a/src/locales/fr/index.ts
+++ b/src/locales/fr/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const frTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const frTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/hu/discordBanner.ts b/src/locales/hu/discordBanner.ts
new file mode 100644
index 00000000..536f5f93
--- /dev/null
+++ b/src/locales/hu/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Csatlakozz a Psysonic-hoz Discordon',
+ message: 'Csatlakozz a Psysonic közösséghez Discordon',
+ join: 'Csatlakozás',
+ neverShow: 'Soha többé ne mutasd',
+ close: 'Elvetés',
+};
\ No newline at end of file
diff --git a/src/locales/hu/index.ts b/src/locales/hu/index.ts
index c13b2dca..2afc08a6 100644
--- a/src/locales/hu/index.ts
+++ b/src/locales/hu/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const huTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const huTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/ja/discordBanner.ts b/src/locales/ja/discordBanner.ts
new file mode 100644
index 00000000..2916556b
--- /dev/null
+++ b/src/locales/ja/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'DiscordでPsysonicに参加',
+ message: 'DiscordのPsysonicコミュニティに参加しよう',
+ join: '参加する',
+ neverShow: '二度と表示しない',
+ close: '閉じる',
+};
\ No newline at end of file
diff --git a/src/locales/ja/index.ts b/src/locales/ja/index.ts
index af7402bb..ff40ef49 100644
--- a/src/locales/ja/index.ts
+++ b/src/locales/ja/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const jaTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const jaTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/nb/discordBanner.ts b/src/locales/nb/discordBanner.ts
new file mode 100644
index 00000000..2b5b1049
--- /dev/null
+++ b/src/locales/nb/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Bli med Psysonic på Discord',
+ message: 'Bli med i Psysonic-samfunnet på Discord',
+ join: 'Bli med',
+ neverShow: 'Aldri vis igjen',
+ close: 'Avvis',
+};
\ No newline at end of file
diff --git a/src/locales/nb/index.ts b/src/locales/nb/index.ts
index 83dde7b3..91f58703 100644
--- a/src/locales/nb/index.ts
+++ b/src/locales/nb/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const nbTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const nbTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/nl/discordBanner.ts b/src/locales/nl/discordBanner.ts
new file mode 100644
index 00000000..2e3e2d32
--- /dev/null
+++ b/src/locales/nl/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Word lid van Psysonic op Discord',
+ message: 'Word lid van de Psysonic-community op Discord',
+ join: 'Deelnemen',
+ neverShow: 'Nooit meer tonen',
+ close: 'Negeren',
+};
\ No newline at end of file
diff --git a/src/locales/nl/index.ts b/src/locales/nl/index.ts
index d64dd563..712ef103 100644
--- a/src/locales/nl/index.ts
+++ b/src/locales/nl/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const nlTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const nlTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/pl/discordBanner.ts b/src/locales/pl/discordBanner.ts
new file mode 100644
index 00000000..a99f50f4
--- /dev/null
+++ b/src/locales/pl/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Dołącz do Psysonic na Discordzie',
+ message: 'Dołącz do społeczności Psysonic na Discordzie',
+ join: 'Dołącz',
+ neverShow: 'Nie pokazuj ponownie',
+ close: 'Odrzuć',
+};
\ No newline at end of file
diff --git a/src/locales/pl/index.ts b/src/locales/pl/index.ts
index e8dbb2e6..4decd1c5 100644
--- a/src/locales/pl/index.ts
+++ b/src/locales/pl/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const plTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const plTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/ro/discordBanner.ts b/src/locales/ro/discordBanner.ts
new file mode 100644
index 00000000..e1491ace
--- /dev/null
+++ b/src/locales/ro/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Alătură-te lui Psysonic pe Discord',
+ message: 'Alătură-te comunității Psysonic pe Discord',
+ join: 'Alătură-te',
+ neverShow: 'Nu mai arăta niciodată',
+ close: 'Omite',
+};
\ No newline at end of file
diff --git a/src/locales/ro/index.ts b/src/locales/ro/index.ts
index e338807b..47eb83c0 100644
--- a/src/locales/ro/index.ts
+++ b/src/locales/ro/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const roTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const roTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/ru/discordBanner.ts b/src/locales/ru/discordBanner.ts
new file mode 100644
index 00000000..8048f16b
--- /dev/null
+++ b/src/locales/ru/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: 'Присоединяйтесь к Psysonic в Discord',
+ message: 'Присоединяйтесь к сообществу Psysonic в Discord',
+ join: 'Присоединиться',
+ neverShow: 'Больше не показывать',
+ close: 'Скрыть',
+};
\ No newline at end of file
diff --git a/src/locales/ru/index.ts b/src/locales/ru/index.ts
index f992c658..fec2cb3d 100644
--- a/src/locales/ru/index.ts
+++ b/src/locales/ru/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const ruTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const ruTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/locales/zh/discordBanner.ts b/src/locales/zh/discordBanner.ts
new file mode 100644
index 00000000..dcc3f533
--- /dev/null
+++ b/src/locales/zh/discordBanner.ts
@@ -0,0 +1,7 @@
+export const discordBanner = {
+ ariaLabel: '在 Discord 上加入 Psysonic',
+ message: '加入 Discord 上的 Psysonic 社区',
+ join: '加入',
+ neverShow: '不再显示',
+ close: '忽略',
+};
\ No newline at end of file
diff --git a/src/locales/zh/index.ts b/src/locales/zh/index.ts
index 26d8a4b1..1a64737e 100644
--- a/src/locales/zh/index.ts
+++ b/src/locales/zh/index.ts
@@ -44,6 +44,7 @@ import { orbit } from './orbit';
import { tray } from './tray';
import { licenses } from './licenses';
import { migration } from './migration';
+import { discordBanner } from './discordBanner';
export const zhTranslation = {
sidebar,
@@ -92,4 +93,5 @@ export const zhTranslation = {
tray,
licenses,
migration,
+ discordBanner,
};
diff --git a/src/store/authDiscordBannerActions.ts b/src/store/authDiscordBannerActions.ts
new file mode 100644
index 00000000..d25aeee5
--- /dev/null
+++ b/src/store/authDiscordBannerActions.ts
@@ -0,0 +1,20 @@
+// src/store/authDiscordBannerActions.ts
+
+import type { AuthState } from './authStoreTypes';
+
+export interface DiscordBannerActions {
+ dismissDiscordBanner: () => void;
+ addDiscordBannerUsageMs: (deltaMs: number) => void;
+}
+
+export const createDiscordBannerActions = (
+ set: (fn: (state: AuthState) => Partial) => void
+): DiscordBannerActions => ({
+ dismissDiscordBanner: () =>
+ set(() => ({ discordBannerDismissed: true })),
+
+ addDiscordBannerUsageMs: deltaMs =>
+ set(state => ({
+ discordBannerAccumulatedUsageMs: state.discordBannerAccumulatedUsageMs + deltaMs,
+ })),
+});
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
index c647f8f3..55c6e405 100644
--- a/src/store/authStore.ts
+++ b/src/store/authStore.ts
@@ -24,12 +24,13 @@ import { syncAllServerHttpContexts } from '@/lib/server/syncServerHttpContext';
import type { AuthState } from './authStoreTypes';
import { getCachedConnectBaseUrl } from '@/lib/server/serverEndpoint';
import { serverProfileBaseUrl } from '@/lib/server/serverBaseUrl';
-
-
+import { createDiscordBannerActions } from './authDiscordBannerActions';
export const useAuthStore = create()(
persist(
(set, get) => ({
+ discordBannerDismissed: false,
+ discordBannerAccumulatedUsageMs: 0,
servers: [],
activeServerId: null,
musicNetworkAccounts: [],
@@ -148,6 +149,7 @@ export const useAuthStore = create()(
...createSkipStarActions(set, get),
...createMusicLibraryActions(set, get),
...createPerServerCapabilityActions(set),
+ ...createDiscordBannerActions(set),
getBaseUrl: () => {
const s = get();
diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts
index 6f142175..5221391e 100644
--- a/src/store/authStoreTypes.ts
+++ b/src/store/authStoreTypes.ts
@@ -109,6 +109,12 @@ export type TrackPreviewLocation =
export type TrackPreviewLocations = Record;
export interface AuthState {
+ // DiscordBanner
+ discordBannerDismissed: boolean;
+ discordBannerAccumulatedUsageMs: number;
+ dismissDiscordBanner: () => void;
+ addDiscordBannerUsageMs: (deltaMs: number) => void;
+
// Multi-server
servers: ServerProfile[];
activeServerId: string | null;
diff --git a/src/styles/layout/discord-banner.css b/src/styles/layout/discord-banner.css
new file mode 100644
index 00000000..b96e028f
--- /dev/null
+++ b/src/styles/layout/discord-banner.css
@@ -0,0 +1,93 @@
+/* src/styles/layout/discord-banner.css */
+
+.discord-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 16px;
+ height: 36px;
+ background: #5865f2;
+ color: #fff;
+ font-size: 13px;
+ flex-shrink: 0;
+ z-index: 50;
+}
+
+.discord-banner-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.discord-banner-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+
+.discord-banner-join {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 12px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.18);
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ border: 1px solid rgba(255, 255, 255, 0.35);
+ white-space: nowrap;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.discord-banner-join:hover {
+ background: rgba(255, 255, 255, 0.28);
+}
+
+.discord-banner-right {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.discord-banner-never {
+ padding: 2px 10px;
+ border-radius: 4px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.75);
+ font-size: 11px;
+ font-weight: 500;
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color 0.15s, background 0.15s;
+}
+
+.discord-banner-never:hover {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.discord-banner-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 4px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.6);
+ border: none;
+ cursor: pointer;
+ transition: color 0.15s, background 0.15s;
+}
+
+.discord-banner-close:hover {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.15);
+}
diff --git a/src/styles/layout/index.css b/src/styles/layout/index.css
index 7e88200a..097eccf6 100644
--- a/src/styles/layout/index.css
+++ b/src/styles/layout/index.css
@@ -29,3 +29,4 @@
@import './empty-state.css';
@import './utility-footer.css';
@import './sidebar-expandable-playlists-section.css';
+@import './discord-banner.css';