mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 18:13:31 +00:00
Merge branch 'development' into related-similar-songs
This commit is contained in:
commit
132b0e173f
69 changed files with 891 additions and 565 deletions
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
6
src/renderer/api/features.types.ts
Normal file
6
src/renderer/api/features.types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export enum ServerFeature {
|
||||
SMART_PLAYLISTS = 'smartPlaylists',
|
||||
SONG_LYRICS = 'songLyrics',
|
||||
}
|
||||
|
||||
export type ServerFeatures = Record<Partial<ServerFeature>, boolean>;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[]> => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -675,6 +675,10 @@ const similarSongs = pagination.extend({
|
|||
Items: z.array(song),
|
||||
});
|
||||
|
||||
export enum JellyfinExtensions {
|
||||
SONG_LYRICS = 'songLyrics',
|
||||
}
|
||||
|
||||
export const jfType = {
|
||||
_enum: {
|
||||
albumArtistList: albumArtistListSort,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }>`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
54
src/renderer/hooks/use-server-authenticated.ts
Normal file
54
src/renderer/hooks/use-server-authenticated.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
4
src/renderer/preload.d.ts
vendored
4
src/renderer/preload.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue