mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts
Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.
* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports
Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:
- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
(Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON
lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.
* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets
Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).
- client.ts: transport wrapper over audioscrobbler_request, classifying
failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
token-poll presets (both endpoints verified live)
Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.
* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets
Add the scrobble-destination wires and presets:
- ListenBrainz wire (scrobble + now playing via playing_now), backing both
the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
(scrobble-only, session-key paste, bundled keys — verified live), and
custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix
maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.
* feat(music-network): registry, orchestrator, enrichment router + runtime facade
Wire the framework together behind a single facade:
- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
final authority (lets two presets on one wire diverge, e.g. Rocksky's
nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel
Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.
* feat(music-network): auth-store state + lossless legacy migration + runtime bridge
Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.
- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
username and scrobbling preference; fills bundled Last.fm key from the preset;
sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap
nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.
* feat(music-network): route playback, enrichment and love through the runtime
Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:
- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
the ArtistDetail similar-artists gate now use the runtime, gated on an
enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
lastfmActions -> networkLoveActions; loved cache storage renamed with a
lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
throw) before the runtime is initialized
src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.
* feat(music-network): manifest-driven Integrations UI + scrobble batch format
Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.
- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey
Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).
* fix(music-network): clearer Integrations layout + scrobble batch fix
Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
liked tracks/similar artists/stats come from it while scrobbling still goes
to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)
Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.
* feat(music-network): indicator + remove legacy lastfm path
Phase 7b/7c — finish the cutover and delete the old Last.fm path.
- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
tests; update settingsTabs + src/CLAUDE.md
No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.
* test(music-network): cover scrobble shape + error classification, drop dead i18n keys
Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.
Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.
* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets
- de-hardcode the single provider name across every enrichment surface
(love labels, now-playing badge, stats title); derive it from the
enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry
* fix(music-network): validate paste-auth keys on connect + UI polish
- AudioscrobblerWire.probe now validates an api_key_only session with a
signed call and reports scrobble:'error' only on a genuine auth failure
(a scrobble-only service that rejects user.getInfo is not a bad key), so
an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour
* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments
Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.
* docs(changelog): Music Network entry (PR #1066)
Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
* fix(music-network): bound request timeout on the provider transports
audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.
* refactor(music-network): dedupe wire transport + no-enrichment helpers
The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.
* refactor(music-network): drop write-only malojaWireMode dead state
malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.
* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback
The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.
---------
Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
This commit is contained in:
@@ -30,14 +30,6 @@ vi.mock('@/api/subsonic', () => ({
|
||||
unstar: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ContextMenu() {
|
||||
const navigate = useNavigate();
|
||||
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, networkLovedCache, setNetworkLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
|
||||
useShallow(s => ({
|
||||
contextMenu: s.contextMenu,
|
||||
closeContextMenu: s.closeContextMenu,
|
||||
@@ -58,8 +58,8 @@ export default function ContextMenu() {
|
||||
queueItems: s.queueItems,
|
||||
currentTrack: s.currentTrack,
|
||||
removeTrack: s.removeTrack,
|
||||
lastfmLovedCache: s.lastfmLovedCache,
|
||||
setLastfmLovedForSong: s.setLastfmLovedForSong,
|
||||
networkLovedCache: s.networkLovedCache,
|
||||
setNetworkLovedForSong: s.setNetworkLovedForSong,
|
||||
starredOverrides: s.starredOverrides,
|
||||
setStarredOverride: s.setStarredOverride,
|
||||
openSongInfo: s.openSongInfo,
|
||||
@@ -234,8 +234,8 @@ export default function ContextMenu() {
|
||||
closeContextMenu={closeContextMenu}
|
||||
starredOverrides={starredOverrides}
|
||||
setStarredOverride={setStarredOverride}
|
||||
lastfmLovedCache={lastfmLovedCache}
|
||||
setLastfmLovedForSong={setLastfmLovedForSong}
|
||||
networkLovedCache={networkLovedCache}
|
||||
setNetworkLovedForSong={setNetworkLovedForSong}
|
||||
openSongInfo={openSongInfo}
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
setKeyboardRating={setKeyboardRating}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
|
||||
export default function LastfmIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { lastfmSessionKey, lastfmUsername, lastfmSessionError } = useAuthStore();
|
||||
|
||||
if (!lastfmSessionKey) return null;
|
||||
|
||||
const tooltip = lastfmSessionError
|
||||
? t('connection.lastfmSessionInvalid')
|
||||
: t('connection.lastfmConnected', { user: lastfmUsername });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div
|
||||
className={`connection-led connection-led--${lastfmSessionError ? 'disconnected' : 'connected'}`}
|
||||
/>
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<LastfmIcon size={11} />
|
||||
Last.fm
|
||||
</span>
|
||||
<span className="connection-server">
|
||||
{lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import MiniPlayer from './MiniPlayer';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEnrichmentPrimary } from '../music-network';
|
||||
import { renderPresetIcon } from './settings/musicNetwork/presetIcon';
|
||||
|
||||
/**
|
||||
* Sidebar status indicator for the enrichment primary (the account that drives
|
||||
* love/similar/stats). Mirrors the old Last.fm indicator: green when connected,
|
||||
* red on session error, click → Integrations. Hidden when no primary is set.
|
||||
*/
|
||||
export default function MusicNetworkIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const ep = useEnrichmentPrimary();
|
||||
if (!ep) return null;
|
||||
const { account: primary, icon } = ep;
|
||||
|
||||
const subtitle = primary.sessionError
|
||||
? t('musicNetwork.statusError')
|
||||
: primary.username
|
||||
? `@${primary.username}`
|
||||
: t('musicNetwork.statusConnected');
|
||||
const tooltip = primary.sessionError
|
||||
? t('musicNetwork.statusError')
|
||||
: primary.username
|
||||
? `${primary.label} · @${primary.username}`
|
||||
: primary.label;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div className={`connection-led connection-led--${primary.sessionError ? 'disconnected' : 'connected'}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{renderPresetIcon(icon, 11)}
|
||||
{primary.label}
|
||||
</span>
|
||||
<span className="connection-server">{subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,14 +30,6 @@ vi.mock('@/api/subsonic', () => ({
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import PlayerBar from './PlayerBar';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import MarqueeText from './MarqueeText';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
import { useRadioMprisSync } from '../hooks/useRadioMprisSync';
|
||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||
@@ -61,7 +60,7 @@ export default function PlayerBar() {
|
||||
currentTrack, currentRadio, isPlaying, volume,
|
||||
togglePlay, next, previous, setVolume,
|
||||
stop, toggleRepeat, repeatMode, toggleFullscreen,
|
||||
lastfmLoved, toggleLastfmLove,
|
||||
networkLoved, toggleNetworkLove,
|
||||
isQueueVisible, toggleQueue,
|
||||
starredOverrides,
|
||||
userRatingOverrides,
|
||||
@@ -79,15 +78,15 @@ export default function PlayerBar() {
|
||||
toggleRepeat: s.toggleRepeat,
|
||||
repeatMode: s.repeatMode,
|
||||
toggleFullscreen: s.toggleFullscreen,
|
||||
lastfmLoved: s.lastfmLoved,
|
||||
toggleLastfmLove: s.toggleLastfmLove,
|
||||
networkLoved: s.networkLoved,
|
||||
toggleNetworkLove: s.toggleNetworkLove,
|
||||
isQueueVisible: s.isQueueVisible,
|
||||
toggleQueue: s.toggleQueue,
|
||||
starredOverrides: s.starredOverrides,
|
||||
userRatingOverrides: s.userRatingOverrides,
|
||||
openContextMenu: s.openContextMenu,
|
||||
})));
|
||||
const { lastfmSessionKey } = useAuthStore();
|
||||
const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId);
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const playerBarRef = useRef<HTMLElement>(null);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
@@ -208,9 +207,9 @@ export default function PlayerBar() {
|
||||
previewingTrack={previewingTrack}
|
||||
isStarred={isStarred}
|
||||
toggleStar={toggleStar}
|
||||
lastfmSessionKey={lastfmSessionKey}
|
||||
lastfmLoved={lastfmLoved}
|
||||
toggleLastfmLove={toggleLastfmLove}
|
||||
enrichmentPrimaryId={enrichmentPrimaryId}
|
||||
networkLoved={networkLoved}
|
||||
toggleNetworkLove={toggleNetworkLove}
|
||||
userRatingOverrides={userRatingOverrides}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
navigate={navigatePlaybackLibrary}
|
||||
|
||||
@@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
|
||||
@@ -24,12 +24,6 @@ vi.mock('@/api/subsonic', () => ({
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
interface Props {
|
||||
marginTop: string;
|
||||
showAudiomuseSimilar: boolean;
|
||||
showLastfmSimilar: boolean;
|
||||
showNetworkSimilar: boolean;
|
||||
similarLoading: boolean;
|
||||
similarArtists: SubsonicArtist[];
|
||||
serverSimilarArtists: SubsonicArtist[];
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function ArtistDetailSimilarArtists({
|
||||
marginTop, showAudiomuseSimilar, showLastfmSimilar,
|
||||
marginTop, showAudiomuseSimilar, showNetworkSimilar,
|
||||
similarLoading, similarArtists, serverSimilarArtists,
|
||||
similarCollapsed, setSimilarCollapsed,
|
||||
}: Props) {
|
||||
@@ -41,7 +41,7 @@ export default function ArtistDetailSimilarArtists({
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{showLastfmSimilar && similarLoading ? (
|
||||
{showNetworkSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
const {
|
||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
|
||||
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
|
||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||
playlistSongIds, setPlaylistSongIds,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
const {
|
||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
|
||||
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
|
||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||
playlistSongIds, setPlaylistSongIds,
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
const {
|
||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||
starredOverrides, setStarredOverride, lastfmLovedCache, setLastfmLovedForSong,
|
||||
starredOverrides, setStarredOverride, networkLovedCache, setNetworkLovedForSong,
|
||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||
playlistSongIds, setPlaylistSongIds,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Radio, Heart, ChevronRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, Share2 } from 'lucide-react';
|
||||
import { queueSongStar } from '../../store/pendingStarSync';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '../../music-network';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
|
||||
@@ -13,7 +13,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
||||
const {
|
||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||
starredOverrides, lastfmLovedCache, setLastfmLovedForSong,
|
||||
starredOverrides, networkLovedCache, setNetworkLovedForSong,
|
||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||
playlistSongIds, setPlaylistSongIds,
|
||||
@@ -24,6 +24,9 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const networkPrimary = useEnrichmentPrimary();
|
||||
const networkLabel = networkPrimary?.label ?? '';
|
||||
const networkIcon = networkPrimary?.icon ?? 'custom';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,18 +79,17 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
{auth.enrichmentPrimaryId !== null && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
const loved = networkLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
setNetworkLovedForSong(song.title, song.artist, newLoved);
|
||||
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
|
||||
})}>
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
{renderPresetIcon(networkIcon, 14)}
|
||||
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -4,14 +4,14 @@ import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import { queueSongStar } from '../../store/pendingStarSync';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '../../music-network';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '../../utils/orbit';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||
import type { ContextMenuItemsProps } from './contextMenuItemTypes';
|
||||
@@ -20,7 +20,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
const {
|
||||
type, item, queueIndex, playlistId, playlistSongIndex, shareKindOverride,
|
||||
playTrack, playNext, enqueue, removeTrack, queue, currentTrack, closeContextMenu,
|
||||
starredOverrides, lastfmLovedCache, setLastfmLovedForSong,
|
||||
starredOverrides, networkLovedCache, setNetworkLovedForSong,
|
||||
openSongInfo, userRatingOverrides, setKeyboardRating, keyboardRating,
|
||||
playlistSubmenuOpen, setPlaylistSubmenuOpen, cancelPlaylistSubmenuCloseTimer, onPlaylistSubmenuTriggerMouseLeave,
|
||||
playlistSongIds, setPlaylistSongIds,
|
||||
@@ -31,6 +31,9 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const networkPrimary = useEnrichmentPrimary();
|
||||
const networkLabel = networkPrimary?.label ?? '';
|
||||
const networkIcon = networkPrimary?.icon ?? 'custom';
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
|
||||
@@ -134,18 +137,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
)}
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
{auth.enrichmentPrimaryId !== null && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
const loved = networkLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
setNetworkLovedForSong(song.title, song.artist, newLoved);
|
||||
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
|
||||
})}>
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
{renderPresetIcon(networkIcon, 14)}
|
||||
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -279,18 +281,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||
</div>
|
||||
)}
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
{auth.enrichmentPrimaryId !== null && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
const loved = networkLovedCache[loveKey] ?? false;
|
||||
return (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const newLoved = !loved;
|
||||
setLastfmLovedForSong(song.title, song.artist, newLoved);
|
||||
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
|
||||
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
|
||||
setNetworkLovedForSong(song.title, song.artist, newLoved);
|
||||
void getMusicNetworkRuntime().setTrackLoved({ title: song.title, artist: song.artist }, newLoved);
|
||||
})}>
|
||||
<LastfmIcon size={14} />
|
||||
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
{renderPresetIcon(networkIcon, 14)}
|
||||
{loved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -30,8 +30,8 @@ export interface ContextMenuItemsProps {
|
||||
closeContextMenu: () => void;
|
||||
starredOverrides: Record<string, boolean>;
|
||||
setStarredOverride: (id: string, starred: boolean) => void;
|
||||
lastfmLovedCache: Record<string, boolean>;
|
||||
setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void;
|
||||
networkLovedCache: Record<string, boolean>;
|
||||
setNetworkLovedForSong: (title: string, artist: string, loved: boolean) => void;
|
||||
openSongInfo: (id: string) => void;
|
||||
userRatingOverrides: Record<string, number>;
|
||||
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import type { CoverArtRef } from '../../cover/types';
|
||||
import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
|
||||
import type { ArtistStats, TrackStats } from '../../music-network';
|
||||
import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import { useEnrichmentPrimaryLabel } from '../../hooks/useEnrichmentPrimaryLabel';
|
||||
import { useEnrichmentPrimaryIcon } from '../../hooks/useEnrichmentPrimaryIcon';
|
||||
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||
|
||||
interface HeroProps {
|
||||
track: { title: string; artist: string; album: string; year?: number;
|
||||
@@ -19,16 +21,16 @@ interface HeroProps {
|
||||
genre?: string;
|
||||
playCount?: number;
|
||||
userRatingOverride?: number;
|
||||
lfmTrack: LastfmTrackInfo | null;
|
||||
lfmArtist: LastfmArtistStats | null;
|
||||
networkTrack: TrackStats | null;
|
||||
networkArtist: ArtistStats | null;
|
||||
starred: boolean;
|
||||
lfmLoved: boolean;
|
||||
lfmLoveEnabled: boolean;
|
||||
networkLoved: boolean;
|
||||
networkLoveEnabled: boolean;
|
||||
activeLyricsTab: boolean;
|
||||
coverRef?: CoverArtRef;
|
||||
onNavigate: (path: string) => void;
|
||||
onToggleStar: () => void;
|
||||
onToggleLfmLove: () => void;
|
||||
onToggleNetworkLove: () => void;
|
||||
onOpenLyrics: () => void;
|
||||
}
|
||||
|
||||
@@ -46,8 +48,10 @@ function renderStars(rating?: number) {
|
||||
);
|
||||
}
|
||||
|
||||
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
|
||||
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, networkTrack, networkArtist, starred, networkLoved, networkLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleNetworkLove, onOpenLyrics }: HeroProps) {
|
||||
const { t } = useTranslation();
|
||||
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
|
||||
const networkIcon = useEnrichmentPrimaryIcon();
|
||||
const rating = userRatingOverride ?? track.userRating;
|
||||
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
|
||||
const releaseAge = track.year ? new Date().getFullYear() - track.year : 0;
|
||||
@@ -117,11 +121,11 @@ const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatin
|
||||
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}>
|
||||
<Heart size={18} fill={starred ? 'var(--highlight)' : 'none'} color={starred ? 'var(--highlight)' : 'currentColor'} />
|
||||
</button>
|
||||
{lfmLoveEnabled && (
|
||||
<button onClick={onToggleLfmLove}
|
||||
className={`np-dash-icon-btn np-dash-lfm-btn${lfmLoved ? ' is-loved' : ''}`}
|
||||
data-tooltip={lfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}>
|
||||
<LastfmIcon size={18} />
|
||||
{networkLoveEnabled && (
|
||||
<button onClick={onToggleNetworkLove}
|
||||
className={`np-dash-icon-btn np-dash-network-btn${networkLoved ? ' is-loved' : ''}`}
|
||||
data-tooltip={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}>
|
||||
{renderPresetIcon(networkIcon ?? 'lastfm', 18)}
|
||||
</button>
|
||||
)}
|
||||
<button className="np-dash-icon-btn"
|
||||
@@ -140,40 +144,40 @@ const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatin
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lfmTrack || lfmArtist) && (
|
||||
<div className="np-dash-hero-lfm">
|
||||
<div className="np-dash-hero-lfm-heading">
|
||||
<span className="np-dash-hero-lfm-badge">Last.fm</span>
|
||||
{(networkTrack || networkArtist) && (
|
||||
<div className="np-dash-hero-network">
|
||||
<div className="np-dash-hero-network-heading">
|
||||
<span className="np-dash-hero-network-badge">{networkLabel}</span>
|
||||
</div>
|
||||
{lfmTrack && (
|
||||
<div className="np-dash-hero-lfm-row">
|
||||
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisTrack', 'This track')}</span>
|
||||
<span className="np-dash-hero-lfm-sep">—</span>
|
||||
<span>{t('nowPlaying.listenersN', { n: lfmTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||||
<span className="np-dash-hero-lfm-dot">·</span>
|
||||
<span>{t('nowPlaying.scrobblesN', { n: lfmTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||||
{lfmTrack.userPlaycount != null && (
|
||||
{networkTrack && (
|
||||
<div className="np-dash-hero-network-row">
|
||||
<span className="np-dash-hero-network-scope">{t('nowPlaying.thisTrack', 'This track')}</span>
|
||||
<span className="np-dash-hero-network-sep">—</span>
|
||||
<span>{t('nowPlaying.listenersN', { n: networkTrack.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||||
<span className="np-dash-hero-network-dot">·</span>
|
||||
<span>{t('nowPlaying.scrobblesN', { n: networkTrack.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||||
{networkTrack.userPlaycount != null && (
|
||||
<>
|
||||
<span className="np-dash-hero-lfm-dot">·</span>
|
||||
<span className="np-dash-hero-lfm-you">
|
||||
{t('nowPlaying.playsByYouN', { n: lfmTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||||
<span className="np-dash-hero-network-dot">·</span>
|
||||
<span className="np-dash-hero-network-you">
|
||||
{t('nowPlaying.playsByYouN', { n: networkTrack.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{lfmArtist && (
|
||||
<div className="np-dash-hero-lfm-row">
|
||||
<span className="np-dash-hero-lfm-scope">{t('nowPlaying.thisArtist', 'This artist')}</span>
|
||||
<span className="np-dash-hero-lfm-sep">—</span>
|
||||
<span>{t('nowPlaying.listenersN', { n: lfmArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||||
<span className="np-dash-hero-lfm-dot">·</span>
|
||||
<span>{t('nowPlaying.scrobblesN', { n: lfmArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||||
{lfmArtist.userPlaycount != null && (
|
||||
{networkArtist && (
|
||||
<div className="np-dash-hero-network-row">
|
||||
<span className="np-dash-hero-network-scope">{t('nowPlaying.thisArtist', 'This artist')}</span>
|
||||
<span className="np-dash-hero-network-sep">—</span>
|
||||
<span>{t('nowPlaying.listenersN', { n: networkArtist.listeners.toLocaleString(), defaultValue: '{{n}} listeners' })}</span>
|
||||
<span className="np-dash-hero-network-dot">·</span>
|
||||
<span>{t('nowPlaying.scrobblesN', { n: networkArtist.playcount.toLocaleString(), defaultValue: '{{n}} scrobbles' })}</span>
|
||||
{networkArtist.userPlaycount != null && (
|
||||
<>
|
||||
<span className="np-dash-hero-lfm-dot">·</span>
|
||||
<span className="np-dash-hero-lfm-you">
|
||||
{t('nowPlaying.playsByYouN', { n: lfmArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||||
<span className="np-dash-hero-network-dot">·</span>
|
||||
<span className="np-dash-hero-network-you">
|
||||
{t('nowPlaying.playsByYouN', { n: networkArtist.userPlaycount.toLocaleString(), defaultValue: 'played {{n}}× by you' })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -9,12 +9,14 @@ import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { albumCoverRef } from '../../cover/ref';
|
||||
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import MarqueeText from '../MarqueeText';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
import StarRating from '../StarRating';
|
||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useEnrichmentPrimaryLabel } from '../../hooks/useEnrichmentPrimaryLabel';
|
||||
import { useEnrichmentPrimaryIcon } from '../../hooks/useEnrichmentPrimaryIcon';
|
||||
import { renderPresetIcon } from '../settings/musicNetwork/presetIcon';
|
||||
import {
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemId,
|
||||
@@ -37,9 +39,9 @@ interface Props {
|
||||
previewingTrack: PreviewingTrack | null;
|
||||
isStarred: boolean;
|
||||
toggleStar: () => void;
|
||||
lastfmSessionKey: string | null;
|
||||
lastfmLoved: boolean;
|
||||
toggleLastfmLove: () => void;
|
||||
enrichmentPrimaryId: string | null;
|
||||
networkLoved: boolean;
|
||||
toggleNetworkLove: () => void;
|
||||
userRatingOverrides: Record<string, number>;
|
||||
toggleFullscreen: () => void;
|
||||
navigate: (to: string) => void | Promise<void>;
|
||||
@@ -51,11 +53,13 @@ export function PlayerTrackInfo({
|
||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
|
||||
coverArtId, displayTitle, displayArtist, displayArtistRefs,
|
||||
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
||||
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
||||
enrichmentPrimaryId, networkLoved, toggleNetworkLove,
|
||||
userRatingOverrides, toggleFullscreen,
|
||||
navigate, openContextMenu, t,
|
||||
}: Props) {
|
||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
|
||||
const networkIcon = useEnrichmentPrimaryIcon();
|
||||
const playbackCoverRef = usePlaybackTrackCoverRef(
|
||||
showPreviewMeta ? null : currentTrack ?? undefined,
|
||||
);
|
||||
@@ -197,15 +201,15 @@ export function PlayerTrackInfo({
|
||||
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{currentTrack && !isRadio && lastfmSessionKey && isLayoutVisible('lastfmLove') && (
|
||||
{currentTrack && !isRadio && enrichmentPrimaryId !== null && isLayoutVisible('lastfmLove') && (
|
||||
<button
|
||||
className="player-btn player-btn-sm player-love-btn"
|
||||
onClick={toggleLastfmLove}
|
||||
aria-label={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
|
||||
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
onClick={toggleNetworkLove}
|
||||
aria-label={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||
data-tooltip={networkLoved ? t('contextMenu.networkUnlove', { provider: networkLabel }) : t('contextMenu.networkLove', { provider: networkLabel })}
|
||||
style={{ color: networkLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<LastfmIcon size={15} />
|
||||
{renderPresetIcon(networkIcon ?? 'lastfm', 15)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,67 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { lastfmAuthUrl, lastfmGetSession, lastfmGetToken, lastfmGetUserInfo, type LastfmUserInfo } from '../../api/lastfm';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
|
||||
|
||||
export function IntegrationsTab() {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle');
|
||||
// Polled token is kept here in case future flows need to display or cancel it explicitly.
|
||||
const [, setLfmPendingToken] = useState<string | null>(null);
|
||||
const [lfmError, setLfmError] = useState<string | null>(null);
|
||||
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
||||
lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {});
|
||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||
|
||||
const startLastfmConnect = useCallback(async () => {
|
||||
setLfmError(null);
|
||||
let token: string;
|
||||
try {
|
||||
token = await lastfmGetToken();
|
||||
setLfmPendingToken(token);
|
||||
setLfmState('waiting');
|
||||
await openUrl(lastfmAuthUrl(token));
|
||||
} catch (e: any) {
|
||||
setLfmError(e.message ?? 'Unknown error');
|
||||
setLfmState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll every 2 s until the user authorises or we time out (2 min)
|
||||
const deadline = Date.now() + 120_000;
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) {
|
||||
setLfmState('error');
|
||||
setLfmError('Timed out — please try again.');
|
||||
setLfmPendingToken(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { key, name } = await lastfmGetSession(token);
|
||||
auth.connectLastfm(key, name);
|
||||
setLfmState('idle');
|
||||
setLfmPendingToken(null);
|
||||
} catch (e: any) {
|
||||
// Error 14 = not yet authorised, keep polling
|
||||
if (e.message?.includes('14')) {
|
||||
setTimeout(poll, 2000);
|
||||
} else {
|
||||
setLfmState('error');
|
||||
setLfmError(e.message ?? 'Unknown error');
|
||||
setLfmPendingToken(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 2000);
|
||||
}, [auth]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -82,70 +27,8 @@ export function IntegrationsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last.fm */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.lfmTitle')}
|
||||
icon={<LastfmIcon size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{auth.lastfmSessionKey ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
|
||||
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
|
||||
{lfmUserInfo && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
|
||||
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
|
||||
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={() => auth.disconnectLastfm()}
|
||||
>
|
||||
{t('settings.lfmDisconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : lfmState === 'waiting' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
||||
{t('settings.lfmConnecting')}
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||||
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('settings.lfmConnectDesc')}
|
||||
</p>
|
||||
{lfmState === 'error' && (
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
|
||||
)}
|
||||
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
|
||||
{t('settings.lfmConnect')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
{/* Music Network — scrobbling + enrichment across multiple services */}
|
||||
<MusicNetworkSection />
|
||||
|
||||
{/* Discord Rich Presence */}
|
||||
<SettingsSubSection
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
errorI18nKey,
|
||||
isMusicNetworkError,
|
||||
listPresets,
|
||||
type BuiltinPreset,
|
||||
type PresetId,
|
||||
} from '../../../music-network';
|
||||
import { renderPresetIcon } from './presetIcon';
|
||||
|
||||
/**
|
||||
* "Add a service" list, driven entirely by the preset registry. Token-poll
|
||||
* presets connect immediately (browser flow); paste presets expand an inline
|
||||
* form built from the manifest's `fields`. Adding a provider needs no edit here.
|
||||
*/
|
||||
export function ConnectProviderForm({
|
||||
connectedPresetIds,
|
||||
onConnect,
|
||||
}: {
|
||||
connectedPresetIds: PresetId[];
|
||||
onConnect: (presetId: PresetId, fields: Record<string, string>) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState<PresetId | null>(null);
|
||||
const [fields, setFields] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState<PresetId | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Bundled single-instance presets disappear once connected; self-hosted /
|
||||
// custom presets can be added repeatedly.
|
||||
const available = listPresets().filter(
|
||||
p => !(p.manifest.credentials === 'bundled' && connectedPresetIds.includes(p.manifest.presetId)),
|
||||
);
|
||||
|
||||
const toMessage = (e: unknown): string =>
|
||||
isMusicNetworkError(e) ? t(errorI18nKey(e.code)) : t('musicNetwork.connectFailed');
|
||||
|
||||
const run = async (presetId: PresetId, payload: Record<string, string>) => {
|
||||
// Enforce the manifest's `required` fields client-side so an empty URL/token
|
||||
// gives a clear message instead of falling through to a confusing NETWORK error.
|
||||
const preset = available.find(p => p.manifest.presetId === presetId);
|
||||
const missing = preset?.manifest.fields.find(f => f.required && !(payload[f.name] ?? '').trim());
|
||||
if (missing) {
|
||||
setError(t('musicNetwork.fieldRequired', { field: t(missing.labelKey) }));
|
||||
return;
|
||||
}
|
||||
setBusy(presetId);
|
||||
setError(null);
|
||||
try {
|
||||
await onConnect(presetId, payload);
|
||||
setExpanded(null);
|
||||
setFields({});
|
||||
} catch (e) {
|
||||
setError(toMessage(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onPrimaryAction = (preset: BuiltinPreset) => {
|
||||
const id = preset.manifest.presetId;
|
||||
if (preset.manifest.fields.length === 0) {
|
||||
void run(id, {});
|
||||
} else {
|
||||
setError(null);
|
||||
setFields({});
|
||||
setExpanded(expanded === id ? null : id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{t('musicNetwork.addService')}</div>
|
||||
{available.map(preset => {
|
||||
const id = preset.manifest.presetId;
|
||||
const isExpanded = expanded === id;
|
||||
const isBusy = busy === id;
|
||||
return (
|
||||
<div key={id} className="settings-card" style={{ padding: '0.75rem 1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{preset.manifest.displayName}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(preset.manifest.descriptionKey)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 12px', flexShrink: 0 }}
|
||||
disabled={isBusy}
|
||||
onClick={() => onPrimaryAction(preset)}
|
||||
>
|
||||
{isBusy ? t('musicNetwork.connecting') : t('musicNetwork.connect')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||
{preset.manifest.fields.map(field => (
|
||||
<div className="form-group" key={field.name}>
|
||||
<label style={{ fontSize: 12 }}>{t(field.labelKey)}</label>
|
||||
<input
|
||||
className="input"
|
||||
type={field.type === 'password' ? 'password' : field.type === 'url' ? 'url' : 'text'}
|
||||
placeholder={field.placeholder}
|
||||
value={fields[field.name] ?? ''}
|
||||
onChange={e => setFields(f => ({ ...f, [field.name]: e.target.value }))}
|
||||
/>
|
||||
{field.helpKey && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, whiteSpace: 'pre-line', lineHeight: 1.5 }}>
|
||||
{t(field.helpKey)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||||
disabled={isBusy}
|
||||
onClick={() => void run(id, fields)}
|
||||
>
|
||||
{isBusy ? t('musicNetwork.connecting') : t('musicNetwork.connect')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomSelect from '../../CustomSelect';
|
||||
import type { Account } from '../../../music-network';
|
||||
|
||||
/**
|
||||
* Picks the single enrichment primary (love / similar / stats source). Only
|
||||
* enrichment-eligible accounts are offered; Maloja / ListenBrainz never appear.
|
||||
* Hidden when there are no eligible accounts.
|
||||
*/
|
||||
export function EnrichmentPrimarySelect({
|
||||
accounts,
|
||||
primaryId,
|
||||
onChange,
|
||||
}: {
|
||||
accounts: Account[];
|
||||
primaryId: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const candidates = accounts.filter(a => a.roles.enrichmentEligible);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const options = [
|
||||
{ value: '', label: t('musicNetwork.primaryNone') },
|
||||
...candidates.map(a => ({ value: a.id, label: a.label })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('musicNetwork.primaryLabel')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
|
||||
</div>
|
||||
<CustomSelect
|
||||
value={primaryId ?? ''}
|
||||
options={options}
|
||||
onChange={v => onChange(v || null)}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import type { Account } from '../../../music-network';
|
||||
|
||||
/**
|
||||
* Shown when a Maloja account is connected AND Last.fm scrobbling is enabled —
|
||||
* Maloja can forward scrobbles to Last.fm, so both paths active risks duplicates.
|
||||
*/
|
||||
export function MalojaProxyWarning({ accounts }: { accounts: Account[] }) {
|
||||
const { t } = useTranslation();
|
||||
const hasMaloja = accounts.some(a => a.presetId.startsWith('maloja'));
|
||||
const lastfmScrobbling = accounts.some(a => a.presetId === 'lastfm' && a.scrobbleEnabled);
|
||||
if (!hasMaloja || !lastfmScrobbling) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-privacy-notice" role="note" style={{ marginTop: '0.5rem' }}>
|
||||
<AlertTriangle size={16} className="settings-privacy-notice-icon" aria-hidden="true" />
|
||||
<div className="settings-privacy-notice-body">{t('musicNetwork.malojaProxyWarning')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { showToast } from '../../../utils/ui/toast';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import {
|
||||
errorI18nKey,
|
||||
getMusicNetworkRuntime,
|
||||
isMusicNetworkError,
|
||||
type PresetId,
|
||||
type UserProfile,
|
||||
} from '../../../music-network';
|
||||
import { useMusicNetworkState } from './useMusicNetworkState';
|
||||
import { ScrobbleDestinationCard } from './ScrobbleDestinationCard';
|
||||
import { EnrichmentPrimarySelect } from './EnrichmentPrimarySelect';
|
||||
import { ConnectProviderForm } from './ConnectProviderForm';
|
||||
import { MalojaProxyWarning } from './MalojaProxyWarning';
|
||||
|
||||
/**
|
||||
* Integrations UI for the Music Network framework — replaces the old Last.fm
|
||||
* card. Manifest-driven: connected destinations, the enrichment-primary picker,
|
||||
* the Maloja proxy warning, and the add-a-service list all come from the
|
||||
* registry. Mutations go through the runtime; state is read reactively from the
|
||||
* auth store (see useMusicNetworkState).
|
||||
*/
|
||||
export function MusicNetworkSection() {
|
||||
const { t } = useTranslation();
|
||||
const { accounts, enrichmentPrimaryId, scrobblingMasterEnabled } = useMusicNetworkState();
|
||||
const [primaryProfile, setPrimaryProfile] = useState<UserProfile | null>(null);
|
||||
|
||||
// Profile stats (scrobbles / member-since) for the enrichment primary.
|
||||
useEffect(() => {
|
||||
if (!enrichmentPrimaryId) { setPrimaryProfile(null); return; }
|
||||
let cancelled = false;
|
||||
setPrimaryProfile(null);
|
||||
getMusicNetworkRuntime().getUserProfile()
|
||||
.then(p => { if (!cancelled) setPrimaryProfile(p); })
|
||||
.catch(() => { if (!cancelled) setPrimaryProfile(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [enrichmentPrimaryId]);
|
||||
|
||||
const setMaster = (v: boolean) => useAuthStore.getState().setScrobblingMasterEnabled(v);
|
||||
const toggleScrobble = (id: string, v: boolean) =>
|
||||
getMusicNetworkRuntime().updateAccount(id, { scrobbleEnabled: v });
|
||||
const disconnect = (id: string) => getMusicNetworkRuntime().disconnect(id);
|
||||
|
||||
const setPrimary = (id: string | null) => {
|
||||
try {
|
||||
getMusicNetworkRuntime().setEnrichmentPrimaryId(id);
|
||||
} catch (e) {
|
||||
showToast(isMusicNetworkError(e) ? t(errorI18nKey(e.code)) : t('musicNetwork.connectFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const connect = async (presetId: PresetId, fields: Record<string, string>) => {
|
||||
const account = await getMusicNetworkRuntime().connect(presetId, { fields });
|
||||
// The wire's connect only checks the credential is present; for paste-auth
|
||||
// providers the real validation is the capability probe. Surface a probe
|
||||
// error (e.g. an invalid token) so the connect does not look silently OK.
|
||||
const scrobble = account.capabilities?.scrobble;
|
||||
if (scrobble?.status === 'error') {
|
||||
showToast(
|
||||
t('musicNetwork.connectProbeFailed', { provider: account.label, message: scrobble.message ?? '' }),
|
||||
6000,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const connectedPresetIds = accounts.map(a => a.presetId);
|
||||
|
||||
return (
|
||||
<SettingsSubSection title={t('musicNetwork.title')} icon={<Share2 size={16} />}>
|
||||
<div className="settings-card">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
|
||||
{t('musicNetwork.desc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('musicNetwork.masterToggle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
|
||||
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<EnrichmentPrimarySelect
|
||||
accounts={accounts}
|
||||
primaryId={enrichmentPrimaryId}
|
||||
onChange={setPrimary}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{accounts.map(account => (
|
||||
<ScrobbleDestinationCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
profile={account.id === enrichmentPrimaryId ? primaryProfile : null}
|
||||
onToggleScrobble={v => toggleScrobble(account.id, v)}
|
||||
onDisconnect={() => disconnect(account.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MalojaProxyWarning accounts={accounts} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getPreset, type Account, type UserProfile } from '../../../music-network';
|
||||
import { renderPresetIcon } from './presetIcon';
|
||||
|
||||
/**
|
||||
* One connected account as a single self-contained block: header (icon, label,
|
||||
* status, optional profile stats for the enrichment primary) on top, and a
|
||||
* footer row holding the per-account scrobble toggle + disconnect — so it is
|
||||
* unambiguous which account the toggle belongs to.
|
||||
*/
|
||||
export function ScrobbleDestinationCard({
|
||||
account,
|
||||
profile,
|
||||
onToggleScrobble,
|
||||
onDisconnect,
|
||||
}: {
|
||||
account: Account;
|
||||
profile: UserProfile | null;
|
||||
onToggleScrobble: (enabled: boolean) => void;
|
||||
onDisconnect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const preset = getPreset(account.presetId);
|
||||
const icon = preset?.manifest.icon ?? 'custom';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '10px',
|
||||
background: 'color-mix(in srgb, var(--accent) 8%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header: identity + status + profile stats */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem' }}>
|
||||
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(icon, 20)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontWeight: 600, fontSize: 14 }}>
|
||||
{account.label}
|
||||
<span
|
||||
className={`connection-led connection-led--${account.sessionError ? 'disconnected' : 'connected'}`}
|
||||
data-tooltip={account.sessionError ? t('musicNetwork.statusError') : t('musicNetwork.statusConnected')}
|
||||
/>
|
||||
</div>
|
||||
{account.username && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>@{account.username}</div>
|
||||
)}
|
||||
{profile && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span>{t('musicNetwork.scrobbles', { n: profile.playcount.toLocaleString() })}</span>
|
||||
{profile.registeredAt > 0 && (
|
||||
<span>{t('musicNetwork.memberSince', { year: new Date(profile.registeredAt * 1000).getFullYear() })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: the scrobble toggle (clearly inside this account's block) + disconnect */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
padding: '0.6rem 1rem',
|
||||
borderTop: '1px solid color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<span className="toggle-switch" style={{ flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={account.scrobbleEnabled}
|
||||
onChange={e => onToggleScrobble(e.target.checked)}
|
||||
aria-label={t('musicNetwork.scrobbleHere')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
{t('musicNetwork.scrobbleHere')}
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
{t('musicNetwork.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Globe, Radio, Server, Music2, Headphones } from 'lucide-react';
|
||||
import LastfmIcon from '../../LastfmIcon';
|
||||
import type { PresetIcon } from '../../../music-network';
|
||||
|
||||
/**
|
||||
* Maps a preset manifest icon id to a rendered icon. Feature code references the
|
||||
* manifest's `icon` field — never a provider name — so adding a provider is a
|
||||
* data change, not a component edit.
|
||||
*/
|
||||
export function renderPresetIcon(icon: PresetIcon, size = 16): React.ReactNode {
|
||||
switch (icon) {
|
||||
case 'lastfm':
|
||||
case 'librefm':
|
||||
return <LastfmIcon size={size} />;
|
||||
case 'rocksky':
|
||||
return <Music2 size={size} />;
|
||||
case 'listenbrainz':
|
||||
return <Radio size={size} />;
|
||||
case 'koito':
|
||||
return <Headphones size={size} />;
|
||||
case 'maloja':
|
||||
return <Server size={size} />;
|
||||
case 'custom':
|
||||
default:
|
||||
return <Globe size={size} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { getPreset, type Account } from '../../../music-network';
|
||||
|
||||
/**
|
||||
* Reactive view of the persisted Music Network state for the Integrations UI.
|
||||
* Accounts re-render on any auth-store change; roles are derived from the preset
|
||||
* manifest (static). Mutations go through the runtime (see MusicNetworkSection),
|
||||
* which writes back to the store and re-renders this.
|
||||
*/
|
||||
export function useMusicNetworkState(): {
|
||||
accounts: Account[];
|
||||
enrichmentPrimaryId: string | null;
|
||||
scrobblingMasterEnabled: boolean;
|
||||
} {
|
||||
const { accounts, enrichmentPrimaryId, scrobblingMasterEnabled } = useAuthStore(
|
||||
useShallow(s => ({
|
||||
accounts: s.musicNetworkAccounts,
|
||||
enrichmentPrimaryId: s.enrichmentPrimaryId,
|
||||
scrobblingMasterEnabled: s.scrobblingMasterEnabled,
|
||||
})),
|
||||
);
|
||||
|
||||
const richAccounts = useMemo<Account[]>(
|
||||
() =>
|
||||
accounts.map(a => ({
|
||||
...a,
|
||||
roles:
|
||||
getPreset(a.presetId)?.manifest.defaultRoles
|
||||
?? { scrobble: false, enrichmentEligible: false },
|
||||
})),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
return { accounts: richAccounts, enrichmentPrimaryId, scrobblingMasterEnabled };
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
|
||||
{ tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
|
||||
{ tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
|
||||
{ tab: 'integrations', titleKey: 'settings.lfmTitle', keywords: 'last.fm lastfm scrobble' },
|
||||
{ tab: 'integrations', titleKey: 'musicNetwork.title', keywords: 'last.fm lastfm libre.fm rocksky listenbrainz maloja scrobble scrobbling music network' },
|
||||
{ tab: 'integrations', titleKey: 'settings.discordRichPresence', keywords: 'discord rich presence rpc' },
|
||||
{ tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' },
|
||||
{ tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
|
||||
|
||||
Reference in New Issue
Block a user