feat: "open in spotify" button (#1839)

* feat: open in spotify

* fix: disable native spotify URI by default
This commit is contained in:
riccardo
2026-03-15 19:49:33 +01:00
committed by GitHub
parent f2ab01199f
commit d96b282cae
7 changed files with 118 additions and 7 deletions
+6 -1
View File
@@ -37,7 +37,8 @@
"openApplicationDirectory": "open application directory", "openApplicationDirectory": "open application directory",
"openIn": { "openIn": {
"lastfm": "Open in Last.fm", "lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz" "musicbrainz": "Open in MusicBrainz",
"spotify": "Open in Spotify"
} }
}, },
"common": { "common": {
@@ -924,6 +925,10 @@
"mpvExtraParameters_help": "one per line", "mpvExtraParameters_help": "one per line",
"musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists", "musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists",
"musicbrainz": "show MusicBrainz links", "musicbrainz": "show MusicBrainz links",
"spotify_description": "show links to Spotify on artist/album pages",
"spotify": "show Spotify links",
"nativeSpotify_description": "open in the Spotify app instead of your browser",
"nativeSpotify": "use Spotify app",
"neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available", "neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available",
"neteaseTranslation": "Enable NetEase translations", "neteaseTranslation": "Enable NetEase translations",
"notify": "enable song notifications", "notify": "enable song notifications",
@@ -298,6 +298,8 @@ interface AlbumMetadataExternalLinksProps {
lastFM: boolean; lastFM: boolean;
mbzId?: null | string; mbzId?: null | string;
musicBrainz: boolean; musicBrainz: boolean;
nativeSpotify: boolean;
spotify: boolean;
} }
const AlbumMetadataExternalLinks = ({ const AlbumMetadataExternalLinks = ({
@@ -307,10 +309,12 @@ const AlbumMetadataExternalLinks = ({
lastFM, lastFM,
mbzId, mbzId,
musicBrainz, musicBrainz,
nativeSpotify,
spotify,
}: AlbumMetadataExternalLinksProps) => { }: AlbumMetadataExternalLinksProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!externalLinks || (!lastFM && !musicBrainz)) return null; if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
return ( return (
<Stack gap="xs"> <Stack gap="xs">
@@ -358,6 +362,28 @@ const AlbumMetadataExternalLinks = ({
variant="subtle" variant="subtle"
/> />
) : null} ) : null}
{spotify && (
<ActionIcon
component="a"
href={
nativeSpotify
? `spotify:search:${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`
: `https://open.spotify.com/search/${encodeURIComponent(albumArtist || '')}%20${encodeURIComponent(albumName || '')}`
}
icon="brandSpotify"
iconProps={{
fill: 'default',
size: 'xl',
}}
radius="md"
rel="noopener noreferrer"
target={nativeSpotify ? undefined : '_blank'}
tooltip={{
label: t('action.openIn.spotify'),
}}
variant="subtle"
/>
)}
</Group> </Group>
</Stack> </Stack>
); );
@@ -370,7 +396,7 @@ export const AlbumDetailContent = () => {
albumQueries.detail({ query: { id: albumId }, serverId: server.id }), albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
); );
const { externalLinks, lastFM, musicBrainz } = useExternalLinks(); const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
const comment = detailQuery?.data?.comment; const comment = detailQuery?.data?.comment;
@@ -403,6 +429,8 @@ export const AlbumDetailContent = () => {
lastFM={lastFM} lastFM={lastFM}
mbzId={mbzId || undefined} mbzId={mbzId || undefined}
musicBrainz={musicBrainz} musicBrainz={musicBrainz}
nativeSpotify={nativeSpotify}
spotify={spotify}
/> />
</div> </div>
</div> </div>
@@ -890,7 +890,9 @@ interface AlbumArtistMetadataExternalLinksProps {
lastFM: boolean; lastFM: boolean;
mbzId?: null | string; mbzId?: null | string;
musicBrainz: boolean; musicBrainz: boolean;
nativeSpotify: boolean;
order?: number; order?: number;
spotify: boolean;
} }
const AlbumArtistMetadataExternalLinks = ({ const AlbumArtistMetadataExternalLinks = ({
@@ -899,11 +901,13 @@ const AlbumArtistMetadataExternalLinks = ({
lastFM, lastFM,
mbzId, mbzId,
musicBrainz, musicBrainz,
nativeSpotify,
order, order,
spotify,
}: AlbumArtistMetadataExternalLinksProps) => { }: AlbumArtistMetadataExternalLinksProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!externalLinks || (!lastFM && !musicBrainz)) return null; if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null;
return ( return (
<Grid.Col order={order} span={12}> <Grid.Col order={order} span={12}>
@@ -948,6 +952,27 @@ const AlbumArtistMetadataExternalLinks = ({
variant="subtle" variant="subtle"
/> />
) : null} ) : null}
{spotify && (
<ActionIcon
component="a"
href={
nativeSpotify
? `spotify:search:${encodeURIComponent(artistName || '')}`
: `https://open.spotify.com/search/${encodeURIComponent(artistName || '')}`
}
icon="brandSpotify"
iconProps={{
fill: 'default',
size: 'xl',
}}
rel="noopener noreferrer"
target={nativeSpotify ? undefined : '_blank'}
tooltip={{
label: t('action.openIn.spotify'),
}}
variant="subtle"
/>
)}
</Group> </Group>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
@@ -1050,7 +1075,7 @@ export const AlbumArtistDetailContent = ({
}: AlbumArtistDetailContentProps) => { }: AlbumArtistDetailContentProps) => {
const artistItems = useArtistItems(); const artistItems = useArtistItems();
const artistRadioCount = useArtistRadioCount(); const artistRadioCount = useArtistRadioCount();
const { externalLinks, lastFM, musicBrainz } = useExternalLinks(); const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks();
const { albumArtistId, artistId } = useParams() as { const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string; albumArtistId?: string;
artistId?: string; artistId?: string;
@@ -1136,14 +1161,16 @@ export const AlbumArtistDetailContent = ({
genres={detailQuery.data?.genres} genres={detailQuery.data?.genres}
order={genresOrder} order={genresOrder}
/> />
{externalLinks && (lastFM || musicBrainz) && ( {externalLinks && (lastFM || musicBrainz || spotify) && (
<AlbumArtistMetadataExternalLinks <AlbumArtistMetadataExternalLinks
artistName={detailQuery.data?.name} artistName={detailQuery.data?.name}
externalLinks={externalLinks} externalLinks={externalLinks}
lastFM={lastFM} lastFM={lastFM}
mbzId={mbzId} mbzId={mbzId}
musicBrainz={musicBrainz} musicBrainz={musicBrainz}
nativeSpotify={nativeSpotify}
order={externalLinksOrder} order={externalLinksOrder}
spotify={spotify}
/> />
)} )}
{enabledItem.biography && ( {enabledItem.biography && (
@@ -601,6 +601,48 @@ export const ApplicationSettings = memo(() => {
isHidden: !settings.externalLinks, isHidden: !settings.externalLinks,
title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }), title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
defaultChecked={settings.spotify}
onChange={(e) => {
setSettings({
general: {
...settings,
spotify: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.spotify', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !settings.externalLinks,
title: t('setting.spotify', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
defaultChecked={settings.nativeSpotify}
onChange={(e) => {
setSettings({
general: {
...settings,
nativeSpotify: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.nativeSpotify', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !settings.externalLinks || !settings.spotify,
title: t('setting.nativeSpotify', { postProcess: 'sentenceCase' }),
},
{ {
control: ( control: (
<Switch <Switch
@@ -154,6 +154,8 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
{ key: 'FS_GENERAL_LASTFM_API_KEY', path: ['general', 'lastfmApiKey'], type: 'string' }, { key: 'FS_GENERAL_LASTFM_API_KEY', path: ['general', 'lastfmApiKey'], type: 'string' },
{ key: 'FS_GENERAL_LAST_FM', path: ['general', 'lastFM'], type: 'bool' }, { key: 'FS_GENERAL_LAST_FM', path: ['general', 'lastFM'], type: 'bool' },
{ key: 'FS_GENERAL_MUSIC_BRAINZ', path: ['general', 'musicBrainz'], type: 'bool' }, { key: 'FS_GENERAL_MUSIC_BRAINZ', path: ['general', 'musicBrainz'], type: 'bool' },
{ key: 'FS_GENERAL_SPOTIFY', path: ['general', 'spotify'], type: 'bool' },
{ key: 'FS_GENERAL_SPOTIFY_NATIVE_APP', path: ['general', 'nativeSpotify'], type: 'bool' },
{ key: 'FS_GENERAL_NATIVE_ASPECT_RATIO', path: ['general', 'nativeAspectRatio'], type: 'bool' }, { key: 'FS_GENERAL_NATIVE_ASPECT_RATIO', path: ['general', 'nativeAspectRatio'], type: 'bool' },
{ {
key: 'FS_GENERAL_PLAYERBAR_OPEN_DRAWER', key: 'FS_GENERAL_PLAYERBAR_OPEN_DRAWER',
+6
View File
@@ -477,6 +477,7 @@ export const GeneralSettingsSchema = z.object({
lastfmApiKey: z.string(), lastfmApiKey: z.string(),
musicBrainz: z.boolean(), musicBrainz: z.boolean(),
nativeAspectRatio: z.boolean(), nativeAspectRatio: z.boolean(),
nativeSpotify: z.boolean(),
passwordStore: z.string().optional(), passwordStore: z.string().optional(),
pathReplace: z.string(), pathReplace: z.string(),
pathReplaceWith: z.string(), pathReplaceWith: z.string(),
@@ -499,6 +500,7 @@ export const GeneralSettingsSchema = z.object({
sidebarPlaylistSorting: z.boolean(), sidebarPlaylistSorting: z.boolean(),
sideQueueType: SideQueueTypeSchema, sideQueueType: SideQueueTypeSchema,
skipButtons: SkipButtonsSchema, skipButtons: SkipButtonsSchema,
spotify: z.boolean(),
theme: z.nativeEnum(AppTheme), theme: z.nativeEnum(AppTheme),
themeDark: z.nativeEnum(AppTheme), themeDark: z.nativeEnum(AppTheme),
themeLight: z.nativeEnum(AppTheme), themeLight: z.nativeEnum(AppTheme),
@@ -1129,6 +1131,7 @@ const initialState: SettingsState = {
lastfmApiKey: '', lastfmApiKey: '',
musicBrainz: true, musicBrainz: true,
nativeAspectRatio: false, nativeAspectRatio: false,
nativeSpotify: false,
passwordStore: undefined, passwordStore: undefined,
pathReplace: '', pathReplace: '',
pathReplaceWith: '', pathReplaceWith: '',
@@ -1161,6 +1164,7 @@ const initialState: SettingsState = {
skipBackwardSeconds: 5, skipBackwardSeconds: 5,
skipForwardSeconds: 10, skipForwardSeconds: 10,
}, },
spotify: true,
theme: AppTheme.DEFAULT_DARK, theme: AppTheme.DEFAULT_DARK,
themeDark: AppTheme.DEFAULT_DARK, themeDark: AppTheme.DEFAULT_DARK,
themeLight: AppTheme.DEFAULT_LIGHT, themeLight: AppTheme.DEFAULT_LIGHT,
@@ -2549,6 +2553,8 @@ export const useExternalLinks = () =>
externalLinks: state.general.externalLinks, externalLinks: state.general.externalLinks,
lastFM: state.general.lastFM, lastFM: state.general.lastFM,
musicBrainz: state.general.musicBrainz, musicBrainz: state.general.musicBrainz,
nativeSpotify: state.general.nativeSpotify,
spotify: state.general.spotify,
}), }),
shallow, shallow,
); );
+2 -1
View File
@@ -125,7 +125,7 @@ import {
import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md'; import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md';
import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi'; import { PiMouseLeftClickFill, PiMouseRightClickFill } from 'react-icons/pi';
import { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri'; import { RiPlayListAddLine, RiRepeat2Line, RiRepeatOneLine } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si'; import { SiMusicbrainz, SiSpotify } from 'react-icons/si';
import styles from './icon.module.css'; import styles from './icon.module.css';
@@ -156,6 +156,7 @@ export const AppIcon = {
brandGitHub: LuGithub, brandGitHub: LuGithub,
brandLastfm: FaLastfmSquare, brandLastfm: FaLastfmSquare,
brandMusicBrainz: SiMusicbrainz, brandMusicBrainz: SiMusicbrainz,
brandSpotify: SiSpotify,
cache: LuCloudDownload, cache: LuCloudDownload,
check: LuCheck, check: LuCheck,
clipboardCopy: LuClipboardCopy, clipboardCopy: LuClipboardCopy,