Merge branch 'development' into related-similar-songs

This commit is contained in:
Jeff 2024-03-04 05:04:54 -08:00 committed by GitHub
commit 132b0e173f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 891 additions and 565 deletions

View file

@ -38,6 +38,7 @@
"channel_other": "channels",
"clear": "clear",
"close": "close",
"codec": "codec",
"collapse": "collapse",
"comingSoon": "coming soon…",
"configure": "configure",
@ -363,6 +364,7 @@
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(common.setting_other)",
"shared": "shared $t(entity.playlist_other)",
"tracks": "$t(entity.track_other)"
},
"trackList": {
@ -567,6 +569,8 @@
"skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar",
"skipPlaylistPage": "skip playlist page",
"skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page",
"startMinimized": "start minimized",
"startMinimized_description": "start the application in system tray",
"theme": "theme",
"theme_description": "sets the theme to use for the application",
"themeDark": "theme (dark)",
@ -592,6 +596,7 @@
"bitrate": "bitrate",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"comment": "comment",
"dateAdded": "date added",
"discNumber": "disc",
@ -626,6 +631,7 @@
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"dateAdded": "date added",
"discNumber": "disc number",
"duration": "$t(common.duration)",

View file

@ -206,7 +206,7 @@ const createTray = () => {
tray.setContextMenu(contextMenu);
};
const createWindow = async () => {
const createWindow = async (first = true) => {
if (isDevelopment) {
await installExtensions();
}
@ -350,13 +350,14 @@ const createWindow = async () => {
mainWindow.loadURL(resolveHtmlPath('index.html'));
const startWindowMinimized = store.get('window_start_minimized', false) as boolean;
mainWindow.on('ready-to-show', () => {
if (!mainWindow) {
throw new Error('"mainWindow" is not defined');
}
if (process.env.START_MINIMIZED) {
mainWindow.minimize();
} else {
if (!first || !startWindowMinimized) {
mainWindow.show();
createWinThumbarButtons();
}
@ -608,7 +609,11 @@ if (!singleInstance) {
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow();
if (mainWindow === null) createWindow(false);
else if (!mainWindow.isVisible()) {
mainWindow.show();
createWinThumbarButtons();
}
});
})
.catch(console.log);

View file

@ -1,6 +1,6 @@
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store';
import type { TitleTheme } from '/@/renderer/types';
import { toServerType, type TitleTheme } from '/@/renderer/types';
const store = new Store();
@ -56,9 +56,20 @@ const themeSet = (theme: TitleTheme): void => {
ipcRenderer.send('theme-set', theme);
};
const SERVER_TYPE = toServerType(process.env.SERVER_TYPE);
const env = {
SERVER_LOCK:
SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false,
SERVER_NAME: process.env.SERVER_NAME ?? '',
SERVER_TYPE,
SERVER_URL: process.env.SERVER_URL ?? 'http://',
};
export const localSettings = {
disableMediaKeys,
enableMediaKeys,
env,
fontError,
get,
passwordGet,

View file

@ -54,8 +54,8 @@ import type {
StructuredLyric,
SimilarSongsArgs,
Song,
ServerType,
} from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
@ -177,7 +177,7 @@ const endpoints: ApiController = {
getPlaylistList: ndController.getPlaylistList,
getPlaylistSongList: ndController.getPlaylistSongList,
getRandomSongList: ssController.getRandomSongList,
getServerInfo: ssController.getServerInfo,
getServerInfo: ndController.getServerInfo,
getSimilarSongs: ssController.getSimilarSongs,
getSongDetail: ndController.getSongDetail,
getSongList: ndController.getSongList,

View file

@ -0,0 +1,6 @@
export enum ServerFeature {
SMART_PLAYLISTS = 'smartPlaylists',
SONG_LYRICS = 'songLyrics',
}
export type ServerFeatures = Record<Partial<ServerFeature>, boolean>;

View file

@ -3,7 +3,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import qs from 'qs';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import omitBy from 'lodash/omitBy';
import { z } from 'zod';
import { authenticationFailure } from '/@/renderer/api/utils';

View file

@ -61,6 +61,7 @@ import packageJson from '../../../../package.json';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import isElectron from 'is-electron';
import { ServerFeatures } from '/@/renderer/api/features.types';
const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
@ -959,7 +960,16 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to get server info');
}
return { id: apiClientProps.server?.id, version: res.body.Version };
const features: ServerFeatures = {
smartPlaylists: false,
songLyrics: true,
};
return {
features,
id: apiClientProps.server?.id,
version: res.body.Version,
};
};
const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {

View file

@ -10,8 +10,9 @@ import {
Playlist,
MusicFolder,
Genre,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
const getStreamUrl = (args: {
container?: string;

View file

@ -675,6 +675,10 @@ const similarSongs = pagination.extend({
Items: z.array(song),
});
export enum JellyfinExtensions {
SONG_LYRICS = 'songLyrics',
}
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,

View file

@ -7,7 +7,7 @@ import qs from 'qs';
import { ndType } from './navidrome-types';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components';
import i18n from '/@/i18n/i18n';

View file

@ -1,3 +1,9 @@
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { NavidromeExtensions, ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import {
AlbumArtistDetailArgs,
AlbumArtistDetailResponse,
@ -39,11 +45,11 @@ import {
RemoveFromPlaylistResponse,
RemoveFromPlaylistArgs,
genreListSortMap,
ServerInfo,
ServerInfoArgs,
} from '../types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { hasFeature } from '/@/renderer/api/utils';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features.types';
const authenticate = async (
url: string,
@ -355,6 +361,16 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistR
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
const { query, apiClientProps } = args;
const customQuery = query._custom?.navidrome;
// Smart playlists only became available in 0.48.0. Do not filter for previous versions
if (
customQuery &&
customQuery.smart !== undefined &&
!hasFeature(apiClientProps.server, ServerFeature.SMART_PLAYLISTS)
) {
customQuery.smart = undefined;
}
const res = await ndApiClient(apiClientProps).getPlaylistList({
query: {
@ -363,7 +379,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
_start: query.startIndex,
q: query.searchTerm,
...query._custom?.navidrome,
...customQuery,
},
});
@ -465,6 +481,70 @@ const removeFromPlaylist = async (
return null;
};
const VERSION_INFO: Array<[string, Record<string, number[]>]> = [
['0.48.0', { [ServerFeature.SMART_PLAYLISTS]: [1] }],
];
const getFeatures = (version: string): Record<string, number[]> => {
const cleanVersion = semverCoerce(version);
const features: Record<string, number[]> = {};
let matched = cleanVersion === null;
for (const [version, supportedFeatures] of VERSION_INFO) {
if (!matched) {
matched = semverGte(cleanVersion!, version);
}
if (matched) {
for (const [feature, feat] of Object.entries(supportedFeatures)) {
if (feature in features) {
features[feature].push(...feat);
} else {
features[feature] = feat;
}
}
}
}
return features;
};
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
const { apiClientProps } = args;
// Navidrome will always populate serverVersion
const ping = await ssApiClient(apiClientProps).ping();
if (ping.status !== 200) {
throw new Error('Failed to ping server');
}
const navidromeFeatures: Record<string, number[]> = getFeatures(ping.body.serverVersion!);
if (ping.body.openSubsonic) {
const res = await ssApiClient(apiClientProps).getServerInfo();
if (res.status !== 200) {
throw new Error('Failed to get server extensions');
}
for (const extension of res.body.openSubsonicExtensions) {
navidromeFeatures[extension.name] = extension.versions;
}
}
const features: ServerFeatures = {
smartPlaylists: false,
songLyrics: true,
};
if (navidromeFeatures[NavidromeExtensions.SMART_PLAYLISTS]) {
features[ServerFeature.SMART_PLAYLISTS] = true;
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
};
export const ndController = {
addToPlaylist,
authenticate,
@ -478,6 +558,7 @@ export const ndController = {
getPlaylistDetail,
getPlaylistList,
getPlaylistSongList,
getServerInfo,
getSongDetail,
getSongList,
getUserList,

View file

@ -7,8 +7,9 @@ import {
User,
AlbumArtist,
Genre,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import z from 'zod';
import { ndType } from './navidrome-types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';

View file

@ -343,6 +343,10 @@ const removeFromPlaylistParameters = z.object({
id: z.array(z.string()),
});
export enum NavidromeExtensions {
SMART_PLAYLISTS = 'smartPlaylists',
}
export const ndType = {
_enum: {
albumArtistList: ndAlbumArtistListSort,

View file

@ -2,7 +2,7 @@ import md5 from 'md5';
import { z } from 'zod';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { SubsonicExtensions, ssType } from '/@/renderer/api/subsonic/subsonic-types';
import {
ArtistInfoArgs,
AuthenticationResponse,
@ -29,6 +29,7 @@ import {
Song,
} from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils';
import { ServerFeatures } from '/@/renderer/api/features.types';
const authenticate = async (
url: string,
@ -383,8 +384,13 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to ping server');
}
const features: ServerFeatures = {
smartPlaylists: false,
songLyrics: false,
};
if (!ping.body.openSubsonic || !ping.body.serverVersion) {
return { version: ping.body.version };
return { features, version: ping.body.version };
}
const res = await ssApiClient(apiClientProps).getServerInfo();
@ -393,9 +399,13 @@ const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
throw new Error('Failed to get server extensions');
}
const features: Record<string, number[]> = {};
const subsonicFeatures: Record<string, number[]> = {};
for (const extension of res.body.openSubsonicExtensions) {
features[extension.name] = extension.versions;
subsonicFeatures[extension.name] = extension.versions;
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.songLyrics = true;
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };

View file

@ -1,8 +1,14 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
import {
QueueSong,
LibraryItem,
AlbumArtist,
Album,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
const getCoverArtUrl = (args: {
baseUrl: string | undefined;

View file

@ -260,6 +260,13 @@ const similarSongs = z.object({
.optional(),
});
export enum SubsonicExtensions {
FORM_POST = 'formPost',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
export const ssType = {
_parameters: {
albumList: albumListParameters,

View file

@ -20,6 +20,7 @@ import {
NDUserListSort,
NDGenreListSort,
} from './navidrome.types';
import { ServerFeatures } from '/@/renderer/api/features.types';
export enum LibraryItem {
ALBUM = 'album',
@ -57,13 +58,16 @@ export type User = {
export type ServerListItem = {
credential: string;
features?: ServerFeatures;
id: string;
name: string;
ndCredential?: string;
savePassword?: boolean;
type: ServerType;
url: string;
userId: string | null;
username: string;
version?: string;
};
export enum ServerType {
@ -1141,14 +1145,8 @@ export type FontData = {
export type ServerInfoArgs = BaseEndpointArgs;
export enum SubsonicExtensions {
FORM_POST = 'formPost',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
export type ServerInfo = {
features?: Record<string, number[]>;
features: ServerFeatures;
id?: string;
version: string;
};

View file

@ -2,7 +2,8 @@ import { AxiosHeaders } from 'axios';
import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import { ServerFeature } from '/@/renderer/api/features.types';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
@ -38,3 +39,11 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
useAuthStore.getState().actions.setCurrentServer(null);
}
};
export const hasFeature = (server: ServerListItem | null, feature: ServerFeature): boolean => {
if (!server || !server.features) {
return false;
}
return server.features[feature];
};

View file

@ -4,6 +4,7 @@ import {
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -114,9 +115,11 @@ export const SwiperGridCarousel = ({
isLoading,
uniqueId,
}: SwiperGridCarouselProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const swiperRef = useRef<SwiperCore | any>(null);
const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd();
const [slideCount, setSlideCount] = useState(4);
useEffect(() => {
swiperRef.current?.slideTo(0, 0);
@ -191,23 +194,24 @@ export const SwiperGridCarousel = ({
const handleNext = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
}, [swiperProps?.slidesPerView]);
}, [slideCount, swiperProps?.slidesPerView]);
const handlePrev = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
}, [swiperProps?.slidesPerView]);
}, [slideCount, swiperProps?.slidesPerView]);
const handleOnSlideChange = useCallback((e: SwiperCore) => {
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: (params?.slidesPerView || 4) < slides.length,
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
hasNextPage: slideCount < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
@ -215,82 +219,106 @@ export const SwiperGridCarousel = ({
const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
hasNextPage: slideCount < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
const handleOnReachEnd = useCallback((e: SwiperCore) => {
const { slides, params } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: false,
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
hasPreviousPage: slideCount < slides.length,
});
}, []);
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
const { slides, params } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length,
hasNextPage: slideCount < slides.length,
hasPreviousPage: false,
});
}, []);
const handleOnResize = useCallback((e: SwiperCore) => {
if (!e) return;
const { width } = e;
const slidesPerView = getSlidesPerView(width);
if (!e.params) return;
e.params.slidesPerView = slidesPerView;
}, []);
useLayoutEffect(() => {
const handleResize = () => {
// Use the container div ref and not swiper width, as this value is more accurate
const width = containerRef.current?.clientWidth;
const { activeIndex, params, slides } =
(swiperRef.current as SwiperCore | undefined) ?? {};
const throttledOnResize = throttle(handleOnResize, 200);
if (width) {
const slidesPerView = getSlidesPerView(width);
setSlideCount(slidesPerView);
}
if (activeIndex !== undefined && slides && params?.slidesPerView) {
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: activeIndex + slideCount < slides.length,
hasPreviousPage: activeIndex > 0,
});
}
};
handleResize();
const throttledResize = throttle(handleResize, 200);
window.addEventListener('resize', throttledResize);
return () => {
window.removeEventListener('resize', throttledResize);
};
}, []);
return (
<CarouselContainer
className="grid-carousel"
spacing="md"
>
{title ? (
<Title
{...title}
handleNext={handleNext}
handlePrev={handlePrev}
pagination={pagination}
/>
) : null}
<Swiper
ref={swiperRef}
resizeObserver
modules={[Virtual]}
slidesPerView={4}
spaceBetween={20}
style={{ height: '100%', width: '100%' }}
onBeforeInit={(swiper) => {
swiperRef.current = swiper;
}}
onBeforeResize={handleOnResize}
onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd}
onResize={throttledOnResize}
onSlideChange={handleOnSlideChange}
onZoomChange={handleOnZoomChange}
{...swiperProps}
>
{slides.map((slideContent, index) => {
return (
<SwiperSlide
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
virtualIndex={index}
>
{slideContent}
</SwiperSlide>
);
})}
</Swiper>
<div ref={containerRef}>
{title ? (
<Title
{...title}
handleNext={handleNext}
handlePrev={handlePrev}
pagination={pagination}
/>
) : null}
<Swiper
ref={swiperRef}
resizeObserver
modules={[Virtual]}
slidesPerView={slideCount}
spaceBetween={20}
style={{ height: '100%', width: '100%' }}
onBeforeInit={(swiper) => {
swiperRef.current = swiper;
}}
onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd}
onSlideChange={handleOnSlideChange}
onZoomChange={handleOnZoomChange}
{...swiperProps}
>
{slides.map((slideContent, index) => {
return (
<SwiperSlide
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
virtualIndex={index}
>
{slideContent}
</SwiperSlide>
);
})}
</Swiper>
</div>
</CarouselContainer>
);
};

View file

@ -10,7 +10,7 @@ import styled from 'styled-components';
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
import { Text } from '/@/renderer/components/text';
import { AppRoute } from '/@/renderer/router/routes';
import { ServerType } from '/@/renderer/types';
import { ServerType } from '/@/renderer/api/types';
import { Skeleton } from '/@/renderer/components/skeleton';
const CellContainer = styled(motion.div)<{ height: number }>`

View file

@ -11,9 +11,9 @@ import {
LibraryItem,
AnyLibraryItems,
RatingResponse,
ServerType,
} from '/@/renderer/api/types';
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
export const useUpdateRating = () => {
const queryClient = useQueryClient();

View file

@ -16,12 +16,12 @@ import orderBy from 'lodash/orderBy';
import { generatePath, useNavigate } from 'react-router';
import { api } from '/@/renderer/api';
import { QueryPagination, queryKeys } from '/@/renderer/api/query-keys';
import { BasePaginatedResponse, LibraryItem } from '/@/renderer/api/types';
import { BasePaginatedResponse, LibraryItem, ServerListItem } from '/@/renderer/api/types';
import { getColumnDefs, VirtualTableProps } from '/@/renderer/components/virtual-table';
import { SetContextMenuItems, useHandleTableContextMenu } from '/@/renderer/features/context-menu';
import { AppRoute } from '/@/renderer/router/routes';
import { useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, ServerListItem, TablePagination } from '/@/renderer/types';
import { ListDisplayType, TablePagination } from '/@/renderer/types';
import { useSearchParams } from 'react-router-dom';
import { ListKey, useListStoreByKey } from '../../../store/list.store';

View file

@ -158,6 +158,14 @@ const tableColumns: { [key: string]: ColDef } = {
params.data ? params.data.channels : undefined,
width: 100,
},
codec: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.CODEC,
headerName: i18n.t('table.column.codec'),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.container : undefined,
width: 60,
},
comment: {
cellRenderer: NoteCell,
colId: TableColumn.COMMENT,

View file

@ -60,6 +60,10 @@ export const SONG_TABLE_COLUMNS = [
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
value: TableColumn.BIT_RATE,
},
{
label: i18n.t('table.config.label.codec', { postProcess: 'titleCase' }),
value: TableColumn.CODEC,
},
{
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED,

View file

@ -15,7 +15,7 @@ import {
RiSettings3Fill,
} from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -31,7 +31,7 @@ import {
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {

View file

@ -9,7 +9,7 @@ import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react
import { useListContext } from '../../../context/list-context';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { AlbumArtistListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -21,7 +21,7 @@ import {
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, ServerType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {

View file

@ -522,7 +522,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const handleUpdateRating = useCallback(
(rating: number) => {
if (!ctx.dataNodes || !ctx.data) return;
if (!ctx.dataNodes && !ctx.data) return;
let uniqueServerIds: string[] = [];
let items: AnyLibraryItems = [];

View file

@ -8,7 +8,8 @@ import {
usePlayerStore,
} from '/@/renderer/store';
import { SetActivity } from '@xhayper/discord-rpc';
import { PlayerStatus, ServerType } from '/@/renderer/types';
import { PlayerStatus } from '/@/renderer/types';
import { ServerType } from '/@/renderer/api/types';
const discordRpc = isElectron() ? window.electron.discordRpc : null;
@ -40,7 +41,7 @@ export const useDiscordRpc = () => {
largeImageText: currentSong?.album || 'Unknown album',
smallImageKey: undefined,
smallImageText: currentStatus,
state: artists && `By ${artists}` || "Unknown artist",
state: (artists && `By ${artists}`) || 'Unknown artist',
};
if (currentStatus === PlayerStatus.PLAYING) {

View file

@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { RiFolder2Fill, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { GenreListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -19,7 +19,7 @@ import {
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ListDisplayType, ServerType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {

View file

@ -6,15 +6,16 @@ import {
InternetProviderLyricResponse,
FullLyricsMetadata,
LyricGetQuery,
SubsonicExtensions,
StructuredLyric,
ServerType,
} from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById, useLyricsSettings } from '/@/renderer/store';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
import isElectron from 'is-electron';
import { hasFeature } from '/@/renderer/api/utils';
import { ServerFeature } from '/@/renderer/api/features.types';
const lyricsIpc = isElectron() ? window.electron.lyrics : null;
@ -112,7 +113,7 @@ export const useSongLyricsBySong = (
source: server?.name ?? 'music server',
};
}
} else if (server.features && SubsonicExtensions.SONG_LYRICS in server.features) {
} else if (hasFeature(server, ServerFeature.SONG_LYRICS)) {
const subsonicLyrics = await api.controller
.getStructuredLyrics({
apiClientProps: { server, signal },

View file

@ -134,7 +134,7 @@ export const FullScreenPlayerImage = () => {
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
const { queue } = usePlayerData();
const { opacity, useImageAspectRatio } = useFullScreenPlayerStore();
const { useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = queue.current;
const { color: background } = useFastAverageColor({
algorithm: 'dominant',
@ -250,7 +250,6 @@ export const FullScreenPlayerImage = () => {
<MetadataContainer
className="full-screen-player-image-metadata"
maw="100%"
opacity={opacity}
spacing="xs"
>
<TextTitle
@ -278,7 +277,6 @@ export const FullScreenPlayerImage = () => {
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong?.albumId || '',
})}
transform="uppercase"
w="100%"
weight={600}
>
@ -292,7 +290,6 @@ export const FullScreenPlayerImage = () => {
style={{
textShadow: 'var(--fullscreen-player-text-shadow)',
}}
transform="uppercase"
>
{index > 0 && (
<Text
@ -313,7 +310,6 @@ export const FullScreenPlayerImage = () => {
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
transform="uppercase"
weight={600}
>
{artist.name}

View file

@ -43,11 +43,11 @@ const HeaderItemWrapper = styled.div`
z-index: 2;
`;
interface TransparendGridContainerProps {
interface TransparentGridContainerProps {
opacity: number;
}
const GridContainer = styled.div<TransparendGridContainerProps>`
const GridContainer = styled.div<TransparentGridContainerProps>`
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr;

View file

@ -153,7 +153,7 @@ const Controls = () => {
defaultValue={opacity}
label={(e) => `${e} %`}
max={100}
min={1}
min={0}
w="100%"
onChangeEnd={(e) => setStore({ opacity: Number(e) })}
/>

View file

@ -15,11 +15,12 @@ import {
ServerType,
GenreListSort,
SortOrder,
ServerListItem,
} from '/@/renderer/api/types';
import { api } from '/@/renderer/api';
import { useAuthStore } from '/@/renderer/store';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Play, PlayQueueAddOptions, ServerListItem } from '/@/renderer/types';
import { Play, PlayQueueAddOptions } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
interface ShuffleAllSlice extends RandomSongListQuery {

View file

@ -8,8 +8,9 @@ import {
SongListResponse,
SongListSort,
SortOrder,
ServerListItem,
ServerType,
} from '/@/renderer/api/types';
import { ServerListItem, ServerType } from '/@/renderer/types';
export const getPlaylistSongsById = async (args: {
id: string;

View file

@ -11,6 +11,8 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { useCurrentServer } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
import { hasFeature } from '/@/renderer/api/utils';
import { ServerFeature } from '/@/renderer/api/features.types';
interface CreatePlaylistFormProps {
onCancel: () => void;
@ -120,12 +122,13 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
})}
/>
)}
{server?.type === ServerType.NAVIDROME && (
<Switch
label="Is smart playlist?"
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
/>
)}
{server?.type === ServerType.NAVIDROME &&
hasFeature(server, ServerFeature.SMART_PLAYLISTS) && (
<Switch
label="Is smart playlist?"
onChange={(e) => setIsSmartPlaylist(e.currentTarget.checked)}
/>
)}
</Group>
{server?.type === ServerType.NAVIDROME && isSmartPlaylist && (
<Stack pt="1rem">

View file

@ -17,7 +17,13 @@ import {
} from 'react-icons/ri';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
import {
LibraryItem,
PlaylistSongListQuery,
ServerType,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import {
DropdownMenu,
Button,
@ -39,7 +45,7 @@ import {
useSetPlaylistStore,
useSetPlaylistTablePagination,
} from '/@/renderer/store';
import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types';
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { useParams, useNavigate } from 'react-router';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';

View file

@ -9,11 +9,11 @@ import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/compon
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, ServerType } from '/@/renderer/types';
import { ListDisplayType } from '/@/renderer/types';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { RiFileAddFill } from 'react-icons/ri';
import { LibraryItem } from '/@/renderer/api/types';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { useListFilterRefresh } from '../../../hooks/use-list-filter-refresh';
import { useListContext } from '/@/renderer/context/list-context';
import { useListStoreByKey } from '../../../store/list.store';

View file

@ -4,12 +4,11 @@ import { nanoid } from 'nanoid/non-secure';
import { useTranslation } from 'react-i18next';
import { generatePath, useNavigate } from 'react-router';
import { createSearchParams } from 'react-router-dom';
import { LibraryItem } from '/@/renderer/api/types';
import { LibraryItem, ServerType } from '/@/renderer/api/types';
import { CreatePlaylistForm } from '/@/renderer/features/playlists';
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
interface HomeCommandsProps {
handleClose: () => void;

View file

@ -3,7 +3,7 @@ import { openModal } from '@mantine/modals';
import { Command, CommandPalettePages } from '/@/renderer/features/search/components/command';
import { ServerList } from '/@/renderer/features/servers';
import { useAuthStoreActions, useServerList } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';

View file

@ -6,9 +6,9 @@ import { useFocusTrap } from '@mantine/hooks';
import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { AuthenticationResponse, ServerType } from '/@/renderer/api/types';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
import { ServerType, toServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
import { useTranslation } from 'react-i18next';
@ -33,15 +33,27 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const form = useForm({
initialValues: {
legacyAuth: false,
name: '',
name: (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) ?? '',
password: '',
savePassword: false,
type: ServerType.JELLYFIN,
url: 'http://',
type:
(localSettings
? localSettings.env.SERVER_TYPE
: toServerType(window.SERVER_TYPE)) ?? ServerType.JELLYFIN,
url: (localSettings ? localSettings.env.SERVER_URL : window.SERVER_URL) ?? 'https://',
username: '',
},
});
// server lock for web is only true if lock is true *and* all other properties are set
const serverLock =
(localSettings
? !!localSettings.env.SERVER_LOCK
: !!window.SERVER_LOCK &&
window.SERVER_TYPE &&
window.SERVER_NAME &&
window.SERVER_URL) || false;
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
const handleSubmit = form.onSubmit(async (values) => {
@ -62,7 +74,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
password: values.password,
username: values.username,
},
values.type,
values.type as ServerType,
);
if (!data) {
@ -76,7 +88,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
id: nanoid(),
name: values.name,
ndCredential: data.ndCredential,
type: values.type,
type: values.type as ServerType,
url: values.url.replace(/\/$/, ''),
userId: data.userId,
username: data.username,
@ -117,11 +129,13 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
>
<SegmentedControl
data={SERVER_TYPES}
disabled={serverLock}
{...form.getInputProps('type')}
/>
<Group grow>
<TextInput
data-autofocus
disabled={serverLock}
label={t('form.addServer.input', {
context: 'name',
postProcess: 'titleCase',
@ -129,6 +143,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
{...form.getInputProps('name')}
/>
<TextInput
disabled={serverLock}
label={t('form.addServer.input', {
context: 'url',
postProcess: 'titleCase',

View file

@ -7,9 +7,8 @@ import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { useTranslation } from 'react-i18next';
import { RiInformationLine } from 'react-icons/ri';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { AuthenticationResponse, ServerListItem, ServerType } from '/@/renderer/api/types';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
import i18n from '/@/i18n/i18n';
import { queryClient } from '/@/renderer/lib/react-query';

View file

@ -7,7 +7,7 @@ import { RiDeleteBin2Line, RiEdit2Fill } from 'react-icons/ri';
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
import { ServerSection } from '/@/renderer/features/servers/components/server-section';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerListItem as ServerItem } from '/@/renderer/types';
import { ServerListItem as ServerItem } from '/@/renderer/api/types';
const localSettings = isElectron() ? window.electron.localSettings : null;

View file

@ -131,6 +131,31 @@ export const WindowSettings = () => {
isHidden: !isElectron(),
title: t('setting.exitToTray', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label="Toggle start in tray"
defaultChecked={settings.startMinimized}
disabled={!isElectron()}
onChange={(e) => {
if (!e) return;
localSettings?.set('window_start_minimized', e.currentTarget.checked);
setSettings({
window: {
...settings,
startMinimized: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.startMinimized', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.startMinimized', { postProcess: 'sentenceCase' }),
},
];
return <SettingsSection options={windowOptions} />;

View file

@ -38,6 +38,9 @@ export const SidebarIcon = ({ active, route, size }: SidebarIconProps) => {
case AppRoute.LIBRARY_ALBUMS:
if (active) return <RiAlbumFill size={size} />;
return <RiAlbumLine size={size} />;
case AppRoute.LIBRARY_ALBUM_ARTISTS:
if (active) return <RiUserVoiceFill size={size} />;
return <RiUserVoiceLine size={size} />;
case AppRoute.LIBRARY_ARTISTS:
if (active) return <RiUserVoiceFill size={size} />;
return <RiUserVoiceLine size={size} />;

View file

@ -1,11 +1,11 @@
import { useCallback, useMemo, useState } from 'react';
import { Flex, Group } from '@mantine/core';
import { Box, Flex, Group } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { RiAddBoxFill, RiAddCircleFill, RiPlayFill } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { LibraryItem } from '/@/renderer/api/types';
import { LibraryItem, Playlist } from '/@/renderer/api/types';
import { Button, Text } from '/@/renderer/components';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { usePlaylistList } from '/@/renderer/features/playlists';
@ -14,7 +14,7 @@ import { Play } from '/@/renderer/types';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useHideScrollbar } from '/@/renderer/hooks';
import { useGeneralSettings } from '/@/renderer/store';
import { useCurrentServer, useGeneralSettings } from '/@/renderer/store';
interface SidebarPlaylistListProps {
data: ReturnType<typeof usePlaylistList>['data'];
@ -22,6 +22,20 @@ interface SidebarPlaylistListProps {
const PlaylistRow = ({ index, data, style }: ListChildComponentProps) => {
const { t } = useTranslation();
if (data?.items[index] === null) {
return (
<div style={{ margin: '0.5rem 0', padding: '0 1.5rem', ...style }}>
<Box
fw="600"
sx={{ fontSize: '1.2rem' }}
>
{t('page.sidebar.shared', { postProcess: 'titleCase' })}
</Box>
</div>
);
}
const path = data?.items[index].id
? data.defaultFullPlaylist
? generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, { playlistId: data.items[index].id })
@ -125,6 +139,7 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
const { isScrollbarHidden, hideScrollbarElementProps } = useHideScrollbar(0);
const handlePlayQueueAdd = usePlayQueueAdd();
const { defaultFullPlaylist } = useGeneralSettings();
const { type, username } = useCurrentServer() || {};
const [rect, setRect] = useState({
height: 0,
@ -147,12 +162,30 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
);
const memoizedItemData = useMemo(() => {
return {
defaultFullPlaylist,
handlePlay: handlePlayPlaylist,
items: data?.items,
};
}, [data?.items, defaultFullPlaylist, handlePlayPlaylist]);
const base = { defaultFullPlaylist, handlePlay: handlePlayPlaylist };
if (!type || !username || !data?.items) {
return { ...base, items: data?.items };
}
const owned: Array<Playlist | null> = [];
const shared: Playlist[] = [];
for (const playlist of data.items) {
if (playlist.owner !== username) {
shared.push(playlist);
} else {
owned.push(playlist);
}
}
if (shared.length > 0) {
// Use `null` as a separator between owned and shared playlists
owned.push(null);
}
return { ...base, items: owned.concat(shared) };
}, [data?.items, defaultFullPlaylist, handlePlayPlaylist, type, username]);
return (
<Flex
@ -168,7 +201,7 @@ export const SidebarPlaylistList = ({ data }: SidebarPlaylistListProps) => {
: 'overlay-scrollbar'
}
height={debounced.height}
itemCount={data?.items?.length || 0}
itemCount={memoizedItemData?.items?.length || 0}
itemData={memoizedItemData}
itemSize={25}
overscanCount={20}

View file

@ -15,7 +15,7 @@ import {
} from 'react-icons/ri';
import { useListStoreByKey } from '../../../store/list.store';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, SongListSort, SortOrder } from '/@/renderer/api/types';
import { LibraryItem, ServerType, SongListSort, SortOrder } from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -27,7 +27,7 @@ import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
import { queryClient } from '/@/renderer/lib/react-query';
import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
const FILTERS = {

View file

@ -29,7 +29,7 @@ import {
useSidebarStore,
useAppStoreActions,
} from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { ServerListItem, ServerType } from '/@/renderer/api/types';
import packageJson from '../../../../../package.json';
const browser = isElectron() ? window.electron.browser : null;

View file

@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCurrentServer } from '/@/renderer/store';
import { AuthState, ServerListItem, ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
import { SongListSort, SortOrder } from '/@/renderer/api/types';
import { debounce } from 'lodash';
export const useServerAuthenticated = () => {
const priorServerId = useRef<string>();
const server = useCurrentServer();
const [ready, setReady] = useState(
server?.type === ServerType.NAVIDROME ? AuthState.LOADING : AuthState.VALID,
);
const authenticateNavidrome = useCallback(async (server: ServerListItem) => {
// This trick works because navidrome-api.ts will internally check for authentication
// failures and try to log in again (where available). So, all that's necessary is
// making one request first
try {
await api.controller.getSongList({
apiClientProps: { server },
query: {
limit: 1,
sortBy: SongListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
});
setReady(AuthState.VALID);
} catch (error) {
setReady(AuthState.INVALID);
}
}, []);
const debouncedAuth = debounce((server: ServerListItem) => {
authenticateNavidrome(server).catch(console.error);
}, 300);
useEffect(() => {
if (priorServerId.current !== server?.id) {
priorServerId.current = server?.id || '';
if (server?.type === ServerType.NAVIDROME) {
setReady(AuthState.LOADING);
debouncedAuth(server);
} else {
setReady(AuthState.VALID);
}
}
}, [debouncedAuth, server]);
return ready;
};

View file

@ -3,6 +3,7 @@ import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '/@/renderer/api/query-keys';
import { controller } from '/@/renderer/api/controller';
import isEqual from 'lodash/isEqual';
export const useServerVersion = () => {
const { updateServer } = useAuthStoreActions();
@ -22,14 +23,18 @@ export const useServerVersion = () => {
});
useEffect(() => {
if (server && server.id === serverInfo.data?.id) {
const { version, features } = serverInfo.data;
if (version !== server.version) {
if (!server?.id) {
return;
}
if (server?.id === serverInfo.data?.id) {
const { version, features } = serverInfo.data || {};
if (version !== server?.version || !isEqual(features, server?.features)) {
updateServer(server.id, {
features,
version,
});
}
}
}, [server, serverInfo.data, updateServer]);
}, [serverInfo?.data, server?.features, server?.id, server?.version, updateServer]);
};

View file

@ -6,6 +6,9 @@
<meta http-equiv="Content-Security-Policy" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Feishin</title>
<% if (web) { %>
<script src="settings.js"></script>
<% } %>
</head>
<body>

View file

@ -13,6 +13,10 @@ import { Browser } from '/@/main/preload/browser';
declare global {
interface Window {
SERVER_LOCK?: boolean;
SERVER_NAME?: string;
SERVER_TYPE?: string;
SERVER_URL?: string;
electron: {
browser: Browser;
discordRpc: DiscordRpc;

View file

@ -3,7 +3,9 @@ import isElectron from 'is-electron';
import { Navigate, Outlet } from 'react-router-dom';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useSetPlayerFallback } from '/@/renderer/store';
import { toast } from '/@/renderer/components';
import { Spinner, toast } from '/@/renderer/components';
import { useServerAuthenticated } from '/@/renderer/hooks/use-server-authenticated';
import { AuthState } from '/@/renderer/types';
const ipc = isElectron() ? window.electron.ipc : null;
const utils = isElectron() ? window.electron.utils : null;
@ -12,6 +14,7 @@ const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : nul
export const AppOutlet = () => {
const currentServer = useCurrentServer();
const setFallback = useSetPlayerFallback();
const authState = useServerAuthenticated();
const isActionsRequired = useMemo(() => {
const isServerRequired = !currentServer;
@ -37,7 +40,11 @@ export const AppOutlet = () => {
};
}, [setFallback]);
if (isActionsRequired) {
if (authState === AuthState.LOADING) {
return <Spinner container />;
}
if (isActionsRequired || authState === AuthState.INVALID) {
return (
<Navigate
replace

View file

@ -6,7 +6,7 @@ import { immer } from 'zustand/middleware/immer';
import { useAlbumArtistListDataStore } from '/@/renderer/store/album-artist-list-data.store';
import { useAlbumListDataStore } from '/@/renderer/store/album-list-data.store';
import { useListStore } from '/@/renderer/store/list.store';
import { ServerListItem } from '/@/renderer/types';
import { ServerListItem } from '/@/renderer/api/types';
export interface AuthState {
currentServer: ServerListItem | null;

View file

@ -267,6 +267,7 @@ export interface SettingsState {
disableAutoUpdate: boolean;
exitToTray: boolean;
minimizeToTray: boolean;
startMinimized: boolean;
windowBarStyle: Platform;
};
}
@ -575,6 +576,7 @@ const initialState: SettingsState = {
disableAutoUpdate: false,
exitToTray: false,
minimizeToTray: false,
startMinimized: false,
windowBarStyle: platformDefaultWindowBarStyle,
},
};

View file

@ -60,6 +60,17 @@ export enum ServerType {
SUBSONIC = 'subsonic',
}
export const toServerType = (value?: string): ServerType | null => {
switch (value?.toLowerCase()) {
case ServerType.JELLYFIN:
return ServerType.JELLYFIN;
case ServerType.NAVIDROME:
return ServerType.NAVIDROME;
default:
return null;
}
};
export type ServerListItem = {
credential: string;
features?: Record<string, number[]>;
@ -144,6 +155,7 @@ export enum TableColumn {
BIT_RATE = 'bitRate',
BPM = 'bpm',
CHANNELS = 'channels',
CODEC = 'codec',
COMMENT = 'comment',
DATE_ADDED = 'dateAdded',
DISC_NUMBER = 'discNumber',
@ -213,3 +225,9 @@ export enum FontType {
}
export type TitleTheme = 'dark' | 'light' | 'system';
export enum AuthState {
INVALID = 'invalid',
LOADING = 'loading',
VALID = 'valid',
}