diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5bc4bcb0..ad60bd42 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -150,6 +150,7 @@ "apiRouteError": "unable to route request", "audioDeviceFetchError": "an error occurred when trying to get audio devices", "authenticationFailed": "authentication failed", + "badAlbum": "you are seeing this page because this song is not part of an album. you are most likely seeing this issue if you have a song at the top level of your music folder. jellyfin only groups tracks if they are in a folder.", "credentialsRequired": "credentials required", "endpointNotImplementedError": "endpoint {{endpoint}} is not implemented for {{serverType}}", "genericError": "an error occurred", diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 2667dace..5dbae8a7 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -185,6 +185,15 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getSongData: { + method: 'GET', + path: 'users/:userId/items/:id', + query: jfType._parameters.songDetail, + responses: { + 200: jfType._response.song, + 400: jfType._response.error, + }, + }, getSongDetail: { method: 'GET', path: 'users/:userId/items/:id', diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index 08d29a2d..a17eaa76 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -134,7 +134,7 @@ const normalizeSong = ( imageUrl: null, name: entry.Name, })), - albumId: item.AlbumId, + albumId: item.AlbumId || `dummy/${item.Id}`, artistName: item?.ArtistItems?.[0]?.Name, artists: item?.ArtistItems?.map((entry) => ({ id: entry.Id, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index a95c8617..3790a21d 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -387,11 +387,13 @@ const genericItem = z.object({ Name: z.string(), }); +const songDetailParameters = baseParameters; + const song = z.object({ Album: z.string(), AlbumArtist: z.string(), AlbumArtists: z.array(genericItem), - AlbumId: z.string(), + AlbumId: z.string().optional(), AlbumPrimaryImageTag: z.string(), ArtistItems: z.array(genericItem), Artists: z.array(z.string()), @@ -709,6 +711,7 @@ export const jfType = { search: searchParameters, similarArtistList: similarArtistListParameters, similarSongs: similarSongsParameters, + songDetail: songDetailParameters, songList: songListParameters, updatePlaylist: updatePlaylistParameters, }, diff --git a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx new file mode 100644 index 00000000..e199a22c --- /dev/null +++ b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx @@ -0,0 +1,260 @@ +import { Button, Spinner, Spoiler, Text } from '/@/renderer/components'; +import { + AnimatedPage, + LibraryHeader, + PlayButton, + useCreateFavorite, + useDeleteFavorite, +} from '/@/renderer/features/shared'; +import { Fragment } from 'react'; +import { generatePath, useParams } from 'react-router'; +import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks'; +import { usePlayQueueAdd } from '/@/renderer/features/player'; +import { usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { LibraryItem, SongDetailResponse } from '/@/renderer/api/types'; +import { useCurrentServer } from '/@/renderer/store'; +import { Stack, Group, Box, Center } from '@mantine/core'; +import { Link } from 'react-router-dom'; +import { AppRoute } from '/@/renderer/router/routes'; +import { formatDurationString } from '/@/renderer/utils'; +import { RiErrorWarningLine, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri'; +import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; +import { SONG_ALBUM_PAGE } from '/@/renderer/features/context-menu/context-menu-items'; +import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu'; +import { styled } from 'styled-components'; +import { queryClient } from '/@/renderer/lib/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { useTranslation } from 'react-i18next'; + +const DetailContainer = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; + padding: 1rem 2rem 5rem; + overflow: hidden; +`; + +const DummyAlbumDetailRoute = () => { + const cq = useContainerQuery(); + const { t } = useTranslation(); + + const { albumId } = useParams() as { albumId: string }; + const server = useCurrentServer(); + const queryKey = queryKeys.songs.detail(server?.id || '', albumId); + const detailQuery = useQuery({ + queryFn: ({ signal }) => { + if (!server) throw new Error('Server not found'); + return api.controller.getSongDetail({ + apiClientProps: { server, signal }, + query: { id: albumId }, + }); + }, + queryKey, + }); + + const { color: background, colorId } = useFastAverageColor({ + id: albumId, + src: detailQuery.data?.imageUrl, + srcLoaded: !detailQuery.isLoading, + }); + const handlePlayQueueAdd = usePlayQueueAdd(); + const playButtonBehavior = usePlayButtonBehavior(); + + const createFavoriteMutation = useCreateFavorite({}); + const deleteFavoriteMutation = useDeleteFavorite({}); + + const handleFavorite = async () => { + if (!detailQuery?.data) return; + + const wasFavorite = detailQuery.data.userFavorite; + + try { + if (wasFavorite) { + await deleteFavoriteMutation.mutateAsync({ + query: { + id: [detailQuery.data.id], + type: LibraryItem.SONG, + }, + serverId: detailQuery.data.serverId, + }); + } else { + await createFavoriteMutation.mutateAsync({ + query: { + id: [detailQuery.data.id], + type: LibraryItem.SONG, + }, + serverId: detailQuery.data.serverId, + }); + } + + queryClient.setQueryData(queryKey, { + ...detailQuery.data, + userFavorite: !wasFavorite, + }); + } catch (error) { + console.error(error); + } + }; + + const showGenres = detailQuery?.data?.genres ? detailQuery?.data?.genres.length !== 0 : false; + const comment = detailQuery?.data?.comment; + + const handleGeneralContextMenu = useHandleGeneralContextMenu(LibraryItem.SONG, SONG_ALBUM_PAGE); + + const handlePlay = () => { + handlePlayQueueAdd?.({ + byItemType: { + id: [albumId], + type: LibraryItem.SONG, + }, + playType: playButtonBehavior, + }); + }; + + if (!background || colorId !== albumId) { + return ; + } + + const metadataItems = [ + { + id: 'releaseYear', + secondary: false, + value: detailQuery?.data?.releaseYear, + }, + { + id: 'duration', + secondary: false, + value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration), + }, + ]; + + return ( + + + + + + {metadataItems.map((item, index) => ( + + {index > 0 && } + {item.value} + + ))} + + + {detailQuery?.data?.albumArtists.map((artist) => ( + + {artist.name} + + ))} + + + + + + + + + handlePlay()} /> + + + + + + {showGenres && ( + + + {detailQuery?.data?.genres?.map((genre) => ( + + ))} + + + )} + {comment && ( + + {replaceURLWithHTMLLinks(comment)} + + )} + +
+ + + +

{t('error.badAlbum', { postProcess: 'sentenceCase' })}

+
+
+
+
+ ); +}; + +export default DummyAlbumDetailRoute; diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index 33ae4743..b9bb027f 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -24,6 +24,13 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { divider: true, id: 'showDetails' }, ]; +export const SONG_ALBUM_PAGE: SetContextMenuItems = [ + { id: 'play' }, + { id: 'playLast' }, + { divider: true, id: 'playNext' }, + { divider: true, id: 'addToPlaylist' }, +]; + export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'play' }, { id: 'playLast' }, diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index 81086c12..49602e51 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -55,6 +55,10 @@ const AlbumDetailRoute = lazy( () => import('/@/renderer/features/albums/routes/album-detail-route'), ); +const DummyAlbumDetailRoute = lazy( + () => import('/@/renderer/features/albums/routes/dummy-album-detail-route'), +); + const GenreListRoute = lazy(() => import('/@/renderer/features/genres/routes/genre-list-route')); const SettingsRoute = lazy(() => import('/@/renderer/features/settings/routes/settings-route')); @@ -144,6 +148,11 @@ export const AppRouter = () => { errorElement={} path={AppRoute.LIBRARY_ALBUMS_DETAIL} /> + } + errorElement={} + path={AppRoute.FAKE_LIBRARY_ALBUM_DETAILS} + /> } errorElement={} diff --git a/src/renderer/router/routes.ts b/src/renderer/router/routes.ts index 61ec9dfb..26129838 100644 --- a/src/renderer/router/routes.ts +++ b/src/renderer/router/routes.ts @@ -1,6 +1,7 @@ export enum AppRoute { ACTION_REQUIRED = '/action-required', EXPLORE = '/explore', + FAKE_LIBRARY_ALBUM_DETAILS = '/library/albums/dummy/:albumId', HOME = '/', LIBRARY_ALBUMS = '/library/albums', LIBRARY_ALBUMS_DETAIL = '/library/albums/:albumId',