restructure files onto electron-vite boilerplate
|
|
@ -1,9 +0,0 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { render } from '@testing-library/react';
|
||||
import { App } from '../renderer/app';
|
||||
|
||||
describe('App', () => {
|
||||
it('should render', () => {
|
||||
expect(render(<App />)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,52 +1,53 @@
|
|||
import { PostProcessorModule, TOptions, StringMap } from 'i18next';
|
||||
import { PostProcessorModule, StringMap, TOptions } from 'i18next';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import cs from './locales/cs.json';
|
||||
import de from './locales/de.json';
|
||||
import en from './locales/en.json';
|
||||
import es from './locales/es.json';
|
||||
import fa from './locales/fa.json';
|
||||
import fi from './locales/fi.json';
|
||||
import fr from './locales/fr.json';
|
||||
import ja from './locales/ja.json';
|
||||
import pl from './locales/pl.json';
|
||||
import zhHans from './locales/zh-Hans.json';
|
||||
import de from './locales/de.json';
|
||||
import hu from './locales/hu.json';
|
||||
import id from './locales/id.json';
|
||||
import it from './locales/it.json';
|
||||
import ru from './locales/ru.json';
|
||||
import ptBr from './locales/pt-BR.json';
|
||||
import sr from './locales/sr.json';
|
||||
import sv from './locales/sv.json';
|
||||
import cs from './locales/cs.json';
|
||||
import ja from './locales/ja.json';
|
||||
import ko from './locales/ko.json';
|
||||
import nbNO from './locales/nb-NO.json';
|
||||
import nl from './locales/nl.json';
|
||||
import zhHant from './locales/zh-Hant.json';
|
||||
import fa from './locales/fa.json';
|
||||
import ko from './locales/ko.json';
|
||||
import pl from './locales/pl.json';
|
||||
import ptBr from './locales/pt-BR.json';
|
||||
import ru from './locales/ru.json';
|
||||
import sr from './locales/sr.json';
|
||||
import sv from './locales/sv.json';
|
||||
import ta from './locales/ta.json';
|
||||
import id from './locales/id.json';
|
||||
import fi from './locales/fi.json';
|
||||
import hu from './locales/hu.json';
|
||||
import zhHans from './locales/zh-Hans.json';
|
||||
import zhHant from './locales/zh-Hant.json';
|
||||
|
||||
const resources = {
|
||||
cs: { translation: cs },
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
de: { translation: de },
|
||||
it: { translation: it },
|
||||
ru: { translation: ru },
|
||||
'pt-BR': { translation: ptBr },
|
||||
fa: { translation: fa },
|
||||
fi: { translation: fi },
|
||||
fr: { translation: fr },
|
||||
hu: { translation: hu },
|
||||
id: { translation: id },
|
||||
it: { translation: it },
|
||||
ja: { translation: ja },
|
||||
ko: { translation: ko },
|
||||
'nb-NO': { translation: nbNO },
|
||||
nl: { translation: nl },
|
||||
pl: { translation: pl },
|
||||
'zh-Hans': { translation: zhHans },
|
||||
'zh-Hant': { translation: zhHant },
|
||||
'pt-BR': { translation: ptBr },
|
||||
ru: { translation: ru },
|
||||
sr: { translation: sr },
|
||||
sv: { translation: sv },
|
||||
cs: { translation: cs },
|
||||
nl: { translation: nl },
|
||||
'nb-NO': { translation: nbNO },
|
||||
ta: { translation: ta },
|
||||
id: { translation: id },
|
||||
fi: { translation: fi },
|
||||
hu: { translation: hu },
|
||||
'zh-Hans': { translation: zhHans },
|
||||
'zh-Hant': { translation: zhHant },
|
||||
};
|
||||
|
||||
export const languages = [
|
||||
|
|
@ -141,35 +142,34 @@ export const languages = [
|
|||
];
|
||||
|
||||
const lowerCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'lowerCase',
|
||||
process: (value: string) => {
|
||||
return value.toLocaleLowerCase();
|
||||
},
|
||||
type: 'postProcessor',
|
||||
};
|
||||
|
||||
const upperCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'upperCase',
|
||||
process: (value: string) => {
|
||||
return value.toLocaleUpperCase();
|
||||
},
|
||||
type: 'postProcessor',
|
||||
};
|
||||
|
||||
const titleCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'titleCase',
|
||||
process: (value: string) => {
|
||||
return value.replace(/\S\S*/g, (txt) => {
|
||||
return txt.charAt(0).toLocaleUpperCase() + txt.slice(1).toLowerCase();
|
||||
});
|
||||
},
|
||||
type: 'postProcessor',
|
||||
};
|
||||
|
||||
const ignoreSentenceCaseLanguages = ['de'];
|
||||
|
||||
const sentenceCasePostProcessor: PostProcessorModule = {
|
||||
type: 'postProcessor',
|
||||
name: 'sentenceCase',
|
||||
process: (value: string, _key: string, _options: TOptions<StringMap>, translator: any) => {
|
||||
const sentences = value.split('. ');
|
||||
|
|
@ -185,6 +185,7 @@ const sentenceCasePostProcessor: PostProcessorModule = {
|
|||
})
|
||||
.join('. ');
|
||||
},
|
||||
type: 'postProcessor',
|
||||
};
|
||||
i18n.use(lowerCasePostProcessor)
|
||||
.use(upperCasePostProcessor)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import axios, { AxiosResponse } from 'axios';
|
||||
import { load } from 'cheerio';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
import {
|
||||
LyricSource,
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
} from '../../../../renderer/api/types';
|
||||
LyricSource,
|
||||
} from '.';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const SEARCH_URL = 'https://genius.com/api/search/song';
|
||||
|
||||
|
|
@ -17,20 +18,6 @@ export interface GeniusResponse {
|
|||
response: Response;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
next_page: number;
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
hits: Hit[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Hit {
|
||||
highlights: any[];
|
||||
index: string;
|
||||
|
|
@ -38,6 +25,35 @@ export interface Hit {
|
|||
type: string;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface PrimaryArtist {
|
||||
_type: string;
|
||||
api_path: string;
|
||||
header_image_url: string;
|
||||
id: number;
|
||||
image_url: string;
|
||||
index_character: string;
|
||||
is_meme_verified: boolean;
|
||||
is_verified: boolean;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ReleaseDateComponents {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
next_page: number;
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
_type: string;
|
||||
annotation_count: number;
|
||||
|
|
@ -69,24 +85,9 @@ export interface Result {
|
|||
url: string;
|
||||
}
|
||||
|
||||
export interface PrimaryArtist {
|
||||
_type: string;
|
||||
api_path: string;
|
||||
header_image_url: string;
|
||||
id: number;
|
||||
image_url: string;
|
||||
index_character: string;
|
||||
is_meme_verified: boolean;
|
||||
is_verified: boolean;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ReleaseDateComponents {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
export interface Section {
|
||||
hits: Hit[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
|
|
@ -94,6 +95,27 @@ export interface Stats {
|
|||
unreviewed_annotations: number;
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(url: string): Promise<null | string> {
|
||||
let result: AxiosResponse<string, any>;
|
||||
try {
|
||||
result = await axios.get<string>(url, { responseType: 'text' });
|
||||
} catch (e) {
|
||||
console.error('Genius lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const $ = load(result.data.split('<br/>').join('\n'));
|
||||
const lyricsDiv = $('div.lyrics');
|
||||
|
||||
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
|
||||
|
||||
const lyricSections = $('div[class^=Lyrics__Container]')
|
||||
.map((_, e) => $(e).text())
|
||||
.toArray()
|
||||
.join('\n');
|
||||
return lyricSections;
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||
|
|
@ -133,9 +155,33 @@ export async function getSearchResults(
|
|||
return orderSearchResults({ params, results: songResults });
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
const response = await getSongId(params);
|
||||
if (!response) {
|
||||
console.error('Could not find the song on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyricsBySongId(response.id);
|
||||
if (!lyrics) {
|
||||
console.error('Could not get lyrics on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: response.artist,
|
||||
id: response.id,
|
||||
lyrics,
|
||||
name: response.name,
|
||||
source: LyricSource.GENIUS,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSongId(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
|
||||
): Promise<null | Omit<InternetProviderLyricResponse, 'lyrics'>> {
|
||||
let result: AxiosResponse<GeniusResponse>;
|
||||
try {
|
||||
result = await axios.get(SEARCH_URL, {
|
||||
|
|
@ -162,48 +208,3 @@ async function getSongId(
|
|||
source: LyricSource.GENIUS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(url: string): Promise<string | null> {
|
||||
let result: AxiosResponse<string, any>;
|
||||
try {
|
||||
result = await axios.get<string>(url, { responseType: 'text' });
|
||||
} catch (e) {
|
||||
console.error('Genius lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const $ = load(result.data.split('<br/>').join('\n'));
|
||||
const lyricsDiv = $('div.lyrics');
|
||||
|
||||
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
|
||||
|
||||
const lyricSections = $('div[class^=Lyrics__Container]')
|
||||
.map((_, e) => $(e).text())
|
||||
.toArray()
|
||||
.join('\n');
|
||||
return lyricSections;
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
const response = await getSongId(params);
|
||||
if (!response) {
|
||||
console.error('Could not find the song on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyricsBySongId(response.id);
|
||||
if (!lyrics) {
|
||||
console.error('Could not get lyrics on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: response.artist,
|
||||
id: response.id,
|
||||
lyrics,
|
||||
name: response.name,
|
||||
source: LyricSource.GENIUS,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,76 @@
|
|||
import { ipcMain } from 'electron';
|
||||
|
||||
import { store } from '../settings/index';
|
||||
import {
|
||||
getLyricsBySongId as getGenius,
|
||||
query as queryGenius,
|
||||
getSearchResults as searchGenius,
|
||||
getLyricsBySongId as getGenius,
|
||||
} from './genius';
|
||||
import {
|
||||
getLyricsBySongId as getLrcLib,
|
||||
query as queryLrclib,
|
||||
getSearchResults as searchLrcLib,
|
||||
getLyricsBySongId as getLrcLib,
|
||||
} from './lrclib';
|
||||
import {
|
||||
getLyricsBySongId as getNetease,
|
||||
query as queryNetease,
|
||||
getSearchResults as searchNetease,
|
||||
getLyricsBySongId as getNetease,
|
||||
} from './netease';
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
QueueSong,
|
||||
LyricGetQuery,
|
||||
LyricSource,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { store } from '../settings/index';
|
||||
|
||||
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
|
||||
export enum LyricSource {
|
||||
GENIUS = 'Genius',
|
||||
LRCLIB = 'lrclib.net',
|
||||
NETEASE = 'NetEase',
|
||||
}
|
||||
|
||||
export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {
|
||||
lyrics: LyricsResponse;
|
||||
remote: boolean;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type InternetProviderLyricResponse = {
|
||||
artist: string;
|
||||
id: string;
|
||||
lyrics: string;
|
||||
name: string;
|
||||
source: LyricSource;
|
||||
};
|
||||
|
||||
export type InternetProviderLyricSearchResponse = {
|
||||
artist: string;
|
||||
id: string;
|
||||
name: string;
|
||||
score?: number;
|
||||
source: LyricSource;
|
||||
};
|
||||
|
||||
export type LyricGetQuery = {
|
||||
remoteSongId: string;
|
||||
remoteSource: LyricSource;
|
||||
song: Song;
|
||||
};
|
||||
|
||||
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export type LyricSearchQuery = {
|
||||
album?: string;
|
||||
artist?: string;
|
||||
duration?: number;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type LyricsResponse = string | SynchronizedLyricsArray;
|
||||
|
||||
export type SynchronizedLyricsArray = Array<[number, string]>;
|
||||
|
||||
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
|
||||
type GetFetcher = (id: string) => Promise<null | string>;
|
||||
type SearchFetcher = (
|
||||
params: LyricSearchQuery,
|
||||
) => Promise<InternetProviderLyricSearchResponse[] | null>;
|
||||
type GetFetcher = (id: string) => Promise<string | null>;
|
||||
|
||||
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
|
||||
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
|
||||
|
||||
const FETCHERS: Record<LyricSource, SongFetcher> = {
|
||||
[LyricSource.GENIUS]: queryGenius,
|
||||
|
|
@ -54,7 +94,7 @@ const MAX_CACHED_ITEMS = 10;
|
|||
|
||||
const lyricCache = new Map<string, CachedLyrics>();
|
||||
|
||||
const getRemoteLyrics = async (song: QueueSong) => {
|
||||
const getRemoteLyrics = async (song: any) => {
|
||||
const sources = store.get('lyrics', []) as LyricSource[];
|
||||
|
||||
const cached = lyricCache.get(song.id);
|
||||
|
|
@ -122,7 +162,7 @@ const searchRemoteLyrics = async (params: LyricSearchQuery) => {
|
|||
return results;
|
||||
};
|
||||
|
||||
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null> => {
|
||||
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<null | string> => {
|
||||
const { remoteSongId, remoteSource } = params;
|
||||
const response = await GET_FETCHERS[remoteSource](remoteSongId);
|
||||
|
||||
|
|
@ -133,7 +173,7 @@ const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null
|
|||
return response;
|
||||
};
|
||||
|
||||
ipcMain.handle('lyric-by-song', async (_event, song: QueueSong) => {
|
||||
ipcMain.handle('lyric-by-song', async (_event, song: any) => {
|
||||
const lyric = await getRemoteLyrics(song);
|
||||
return lyric;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
LyricSource,
|
||||
} from '../../../../renderer/api/types';
|
||||
} from '.';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const FETCH_URL = 'https://lrclib.net/api/get';
|
||||
const SEEARCH_URL = 'https://lrclib.net/api/search';
|
||||
|
|
@ -29,10 +30,23 @@ export interface LrcLibTrackResponse {
|
|||
isrc: string;
|
||||
lang: string;
|
||||
name: string;
|
||||
plainLyrics: string | null;
|
||||
plainLyrics: null | string;
|
||||
releaseDate: string;
|
||||
spotifyId: string;
|
||||
syncedLyrics: string | null;
|
||||
syncedLyrics: null | string;
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(songId: string): Promise<null | string> {
|
||||
let result: AxiosResponse<LrcLibTrackResponse, any>;
|
||||
|
||||
try {
|
||||
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
|
||||
} catch (e) {
|
||||
console.error('LrcLib lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.syncedLyrics || result.data.plainLyrics || null;
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
|
|
@ -69,19 +83,6 @@ export async function getSearchResults(
|
|||
return orderSearchResults({ params, results: songResults });
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(songId: string): Promise<string | null> {
|
||||
let result: AxiosResponse<LrcLibTrackResponse, any>;
|
||||
|
||||
try {
|
||||
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
|
||||
} catch (e) {
|
||||
console.error('LrcLib lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.syncedLyrics || result.data.plainLyrics || null;
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
|
|
|
|||
|
|
@ -1,46 +1,18 @@
|
|||
import axios, { AxiosResponse } from 'axios';
|
||||
import { LyricSource } from '../../../../renderer/api/types';
|
||||
import { orderSearchResults } from './shared';
|
||||
import type {
|
||||
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
} from '/@/renderer/api/types';
|
||||
LyricSource,
|
||||
} from '.';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const SEARCH_URL = 'https://music.163.com/api/search/get';
|
||||
const LYRICS_URL = 'https://music.163.com/api/song/lyric';
|
||||
|
||||
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts
|
||||
|
||||
export interface NetEaseResponse {
|
||||
code: number;
|
||||
result: Result;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
hasMore: boolean;
|
||||
songCount: number;
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
album: Album;
|
||||
alias: string[];
|
||||
artists: Artist[];
|
||||
copyrightId: number;
|
||||
duration: number;
|
||||
fee: number;
|
||||
ftype: number;
|
||||
id: number;
|
||||
mark: number;
|
||||
mvid: number;
|
||||
name: string;
|
||||
rUrl: null;
|
||||
rtype: number;
|
||||
status: number;
|
||||
transNames?: string[];
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
artist: Artist;
|
||||
copyrightId: number;
|
||||
|
|
@ -67,6 +39,53 @@ export interface Artist {
|
|||
trans: null;
|
||||
}
|
||||
|
||||
export interface NetEaseResponse {
|
||||
code: number;
|
||||
result: Result;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
hasMore: boolean;
|
||||
songCount: number;
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
album: Album;
|
||||
alias: string[];
|
||||
artists: Artist[];
|
||||
copyrightId: number;
|
||||
duration: number;
|
||||
fee: number;
|
||||
ftype: number;
|
||||
id: number;
|
||||
mark: number;
|
||||
mvid: number;
|
||||
name: string;
|
||||
rtype: number;
|
||||
rUrl: null;
|
||||
status: number;
|
||||
transNames?: string[];
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(songId: string): Promise<null | string> {
|
||||
let result: AxiosResponse<any, any>;
|
||||
try {
|
||||
result = await axios.get(LYRICS_URL, {
|
||||
params: {
|
||||
id: songId,
|
||||
kv: '-1',
|
||||
lv: '-1',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('NetEase lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||
|
|
@ -110,38 +129,6 @@ export async function getSearchResults(
|
|||
return orderSearchResults({ params, results: songResults });
|
||||
}
|
||||
|
||||
async function getMatchedLyrics(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
|
||||
const results = await getSearchResults(params);
|
||||
|
||||
const firstMatch = results?.[0];
|
||||
|
||||
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(songId: string): Promise<string | null> {
|
||||
let result: AxiosResponse<any, any>;
|
||||
try {
|
||||
result = await axios.get(LYRICS_URL, {
|
||||
params: {
|
||||
id: songId,
|
||||
kv: '-1',
|
||||
lv: '-1',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('NetEase lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
|
|
@ -165,3 +152,17 @@ export async function query(
|
|||
source: LyricSource.NETEASE,
|
||||
};
|
||||
}
|
||||
|
||||
async function getMatchedLyrics(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<null | Omit<InternetProviderLyricResponse, 'lyrics'>> {
|
||||
const results = await getSearchResults(params);
|
||||
|
||||
const firstMatch = results?.[0];
|
||||
|
||||
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Fuse from 'fuse.js';
|
||||
|
||||
import {
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import console from 'console';
|
||||
import { rm } from 'fs/promises';
|
||||
import { pid } from 'node:process';
|
||||
import { app, ipcMain } from 'electron';
|
||||
import { rm } from 'fs/promises';
|
||||
import uniq from 'lodash/uniq';
|
||||
import MpvAPI from 'node-mpv';
|
||||
import { getMainWindow, sendToastToRenderer } from '../../../main';
|
||||
import { pid } from 'node:process';
|
||||
|
||||
import { getMainWindow, sendToastToRenderer } from '../../../index';
|
||||
import { createLog, isWindows } from '../../../utils';
|
||||
import { store } from '../settings';
|
||||
|
||||
|
|
@ -86,7 +87,7 @@ const createMpv = async (data: {
|
|||
extraParameters?: string[];
|
||||
properties?: Record<string, any>;
|
||||
}): Promise<MpvAPI> => {
|
||||
const { extraParameters, properties, binaryPath } = data;
|
||||
const { binaryPath, extraParameters, properties } = data;
|
||||
|
||||
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
||||
|
||||
|
|
@ -174,7 +175,7 @@ ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) =>
|
|||
} else {
|
||||
getMpvInstance()?.setMultipleProperties(data);
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to set properties: ${JSON.stringify(data)}` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -199,7 +200,7 @@ ipcMain.handle(
|
|||
mpvInstance = await createMpv(data);
|
||||
mpvLog({ action: 'Restarted mpv', toast: 'success' });
|
||||
setAudioPlayerFallback(false);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to restart mpv, falling back to web player' }, err);
|
||||
setAudioPlayerFallback(true);
|
||||
}
|
||||
|
|
@ -215,7 +216,7 @@ ipcMain.handle(
|
|||
});
|
||||
mpvInstance = await createMpv(data);
|
||||
setAudioPlayerFallback(false);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to initialize mpv, falling back to web player' }, err);
|
||||
setAudioPlayerFallback(true);
|
||||
}
|
||||
|
|
@ -226,7 +227,7 @@ ipcMain.on('player-quit', async () => {
|
|||
try {
|
||||
await getMpvInstance()?.stop();
|
||||
await quit();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to quit mpv' }, err);
|
||||
} finally {
|
||||
mpvInstance = null;
|
||||
|
|
@ -245,7 +246,7 @@ ipcMain.handle('player-clean-up', async () => {
|
|||
ipcMain.on('player-start', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.play();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to start mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -254,7 +255,7 @@ ipcMain.on('player-start', async () => {
|
|||
ipcMain.on('player-play', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.play();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to start mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -263,7 +264,7 @@ ipcMain.on('player-play', async () => {
|
|||
ipcMain.on('player-pause', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.pause();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to pause mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -272,7 +273,7 @@ ipcMain.on('player-pause', async () => {
|
|||
ipcMain.on('player-stop', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.stop();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to stop mpv playback' }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -281,7 +282,7 @@ ipcMain.on('player-stop', async () => {
|
|||
ipcMain.on('player-next', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.next();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to go to next track' }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -290,7 +291,7 @@ ipcMain.on('player-next', async () => {
|
|||
ipcMain.on('player-previous', async () => {
|
||||
try {
|
||||
await getMpvInstance()?.prev();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: 'Failed to go to previous track' }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -299,7 +300,7 @@ ipcMain.on('player-previous', async () => {
|
|||
ipcMain.on('player-seek', async (_event, time: number) => {
|
||||
try {
|
||||
await getMpvInstance()?.seek(time);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to seek by ${time} seconds` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -308,7 +309,7 @@ ipcMain.on('player-seek', async (_event, time: number) => {
|
|||
ipcMain.on('player-seek-to', async (_event, time: number) => {
|
||||
try {
|
||||
await getMpvInstance()?.goToPosition(time);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to seek to ${time} seconds` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -320,7 +321,7 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p
|
|||
await getMpvInstance()?.clearPlaylist();
|
||||
await getMpvInstance()?.pause();
|
||||
return;
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to clear play queue` }, err);
|
||||
}
|
||||
}
|
||||
|
|
@ -344,7 +345,7 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p
|
|||
// Only force play if pause is explicitly false
|
||||
await getMpvInstance()?.play();
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to set play queue` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -365,7 +366,7 @@ ipcMain.on('player-set-queue-next', async (_event, url?: string) => {
|
|||
if (url) {
|
||||
await getMpvInstance()?.load(url, 'append');
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to set play queue` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -385,7 +386,7 @@ ipcMain.on('player-auto-next', async (_event, url?: string) => {
|
|||
if (url) {
|
||||
await getMpvInstance()?.load(url, 'append');
|
||||
}
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to load next song` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -398,7 +399,7 @@ ipcMain.on('player-volume', async (_event, value: number) => {
|
|||
}
|
||||
|
||||
await getMpvInstance()?.volume(value);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to set volume to ${value}` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -407,7 +408,7 @@ ipcMain.on('player-volume', async (_event, value: number) => {
|
|||
ipcMain.on('player-mute', async (_event, mute: boolean) => {
|
||||
try {
|
||||
await getMpvInstance()?.mute(mute);
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to set mute status` }, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -415,7 +416,7 @@ ipcMain.on('player-mute', async (_event, mute: boolean) => {
|
|||
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||
try {
|
||||
return getMpvInstance()?.getTimePosition();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to get current time` }, err);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -442,7 +443,7 @@ app.on('before-quit', async (event) => {
|
|||
event.preventDefault();
|
||||
await getMpvInstance()?.stop();
|
||||
await quit();
|
||||
} catch (err: NodeMpvError | any) {
|
||||
} catch (err: any | NodeMpvError) {
|
||||
mpvLog({ action: `Failed to cleanly before-quit` }, err);
|
||||
} finally {
|
||||
mpvState = MpvState.DONE;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable promise/always-return */
|
||||
import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../../utils';
|
||||
import { store } from '../settings';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,22 @@
|
|||
import { Stats, promises } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { IncomingMessage, Server, ServerResponse, createServer } from 'http';
|
||||
import { join } from 'path';
|
||||
import { deflate, gzip } from 'zlib';
|
||||
import axios from 'axios';
|
||||
import { app, ipcMain } from 'electron';
|
||||
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
|
||||
import manifest from './manifest.json';
|
||||
import { ClientEvent, ServerEvent } from '../../../../remote/types';
|
||||
import { PlayerRepeat, PlayerStatus, SongState } from '../../../../renderer/types';
|
||||
import { getMainWindow } from '../../../main';
|
||||
import { promises, Stats } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import { join } from 'path';
|
||||
import { WebSocket, WebSocketServer, Server as WsServer } from 'ws';
|
||||
import { deflate, gzip } from 'zlib';
|
||||
|
||||
import { getMainWindow } from '../../..';
|
||||
import { isLinux } from '../../../utils';
|
||||
import type { QueueSong } from '/@/renderer/api/types';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
let mprisPlayer: any | undefined;
|
||||
|
||||
if (isLinux()) {
|
||||
// eslint-disable-next-line global-require
|
||||
mprisPlayer = require('../../linux/mpris').mprisPlayer;
|
||||
}
|
||||
|
||||
interface RemoteConfig {
|
||||
enabled: boolean;
|
||||
password: string;
|
||||
port: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface MimeType {
|
||||
css: string;
|
||||
html: string;
|
||||
|
|
@ -34,6 +24,13 @@ interface MimeType {
|
|||
js: string;
|
||||
}
|
||||
|
||||
interface RemoteConfig {
|
||||
enabled: boolean;
|
||||
password: string;
|
||||
port: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
declare class StatefulWebSocket extends WebSocket {
|
||||
alive: boolean;
|
||||
|
||||
|
|
@ -41,7 +38,7 @@ declare class StatefulWebSocket extends WebSocket {
|
|||
}
|
||||
|
||||
let server: Server | undefined;
|
||||
let wsServer: WsServer<typeof StatefulWebSocket> | undefined;
|
||||
let wsServer: undefined | WsServer<typeof StatefulWebSocket>;
|
||||
|
||||
const settings: RemoteConfig = {
|
||||
enabled: false,
|
||||
|
|
@ -54,14 +51,6 @@ type SendData = ServerEvent & {
|
|||
client: StatefulWebSocket;
|
||||
};
|
||||
|
||||
function send({ client, event, data }: SendData): void {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
if (client.alive && client.auth) {
|
||||
client.send(JSON.stringify({ data, event }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast(message: ServerEvent): void {
|
||||
if (wsServer) {
|
||||
for (const client of wsServer.clients) {
|
||||
|
|
@ -70,6 +59,14 @@ function broadcast(message: ServerEvent): void {
|
|||
}
|
||||
}
|
||||
|
||||
function send({ client, data, event }: SendData): void {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
if (client.alive && client.auth) {
|
||||
client.send(JSON.stringify({ data, event }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shutdownServer = () => {
|
||||
if (wsServer) {
|
||||
wsServer.clients.forEach((client) => client.close(4000));
|
||||
|
|
@ -121,21 +118,17 @@ const getEncoding = (encoding: string | string[]): Encoding => {
|
|||
|
||||
const cache = new Map<string, Map<Encoding, [number, Buffer]>>();
|
||||
|
||||
function setOk(
|
||||
res: ServerResponse,
|
||||
mtimeMs: number,
|
||||
extension: keyof MimeType,
|
||||
encoding: Encoding,
|
||||
data?: Buffer,
|
||||
) {
|
||||
res.statusCode = data ? 200 : 304;
|
||||
function authorize(req: IncomingMessage): boolean {
|
||||
if (settings.username || settings.password) {
|
||||
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
|
||||
|
||||
res.setHeader('Content-Type', MIME_TYPES[extension]);
|
||||
res.setHeader('ETag', `"${mtimeMs}"`);
|
||||
res.setHeader('Cache-Control', 'public');
|
||||
const authorization = req.headers.authorization?.split(' ')[1] || '';
|
||||
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
|
||||
|
||||
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
|
||||
res.end(data);
|
||||
return login === settings.username && password === settings.password;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function serveFile(
|
||||
|
|
@ -252,17 +245,21 @@ async function serveFile(
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function authorize(req: IncomingMessage): boolean {
|
||||
if (settings.username || settings.password) {
|
||||
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
|
||||
function setOk(
|
||||
res: ServerResponse,
|
||||
mtimeMs: number,
|
||||
extension: keyof MimeType,
|
||||
encoding: Encoding,
|
||||
data?: Buffer,
|
||||
) {
|
||||
res.statusCode = data ? 200 : 304;
|
||||
|
||||
const authorization = req.headers.authorization?.split(' ')[1] || '';
|
||||
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
|
||||
res.setHeader('Content-Type', MIME_TYPES[extension]);
|
||||
res.setHeader('ETag', `"${mtimeMs}"`);
|
||||
res.setHeader('Cache-Control', 'public');
|
||||
|
||||
return login === settings.username && password === settings.password;
|
||||
}
|
||||
|
||||
return true;
|
||||
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
const enableServer = (config: RemoteConfig): Promise<void> => {
|
||||
|
|
@ -286,28 +283,28 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||
await serveFile(req, 'index', 'html', res);
|
||||
break;
|
||||
}
|
||||
case '/credentials': {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end(req.headers.authorization);
|
||||
break;
|
||||
}
|
||||
case '/favicon.ico': {
|
||||
await serveFile(req, 'favicon', 'ico', res);
|
||||
break;
|
||||
}
|
||||
case '/remote.css': {
|
||||
await serveFile(req, 'remote', 'css', res);
|
||||
break;
|
||||
}
|
||||
case '/remote.js': {
|
||||
await serveFile(req, 'remote', 'js', res);
|
||||
break;
|
||||
}
|
||||
case '/manifest.json': {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(manifest));
|
||||
break;
|
||||
}
|
||||
case '/credentials': {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end(req.headers.authorization);
|
||||
case '/remote.css': {
|
||||
await serveFile(req, 'remote', 'css', res);
|
||||
break;
|
||||
}
|
||||
case '/remote.js': {
|
||||
await serveFile(req, 'remote', 'js', res);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
|
@ -371,6 +368,21 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||
}
|
||||
|
||||
switch (event) {
|
||||
case 'favorite': {
|
||||
const { favorite, id } = json;
|
||||
if (id && id === currentState.song?.id) {
|
||||
getMainWindow()?.webContents.send('request-favorite', {
|
||||
favorite,
|
||||
id,
|
||||
serverId: currentState.song.serverId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'next': {
|
||||
getMainWindow()?.webContents.send('renderer-player-next');
|
||||
break;
|
||||
}
|
||||
case 'pause': {
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
break;
|
||||
|
|
@ -379,10 +391,6 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
break;
|
||||
}
|
||||
case 'next': {
|
||||
getMainWindow()?.webContents.send('renderer-player-next');
|
||||
break;
|
||||
}
|
||||
case 'previous': {
|
||||
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||
break;
|
||||
|
|
@ -421,6 +429,17 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||
|
||||
break;
|
||||
}
|
||||
case 'rating': {
|
||||
const { id, rating } = json;
|
||||
if (id && id === currentState.song?.id) {
|
||||
getMainWindow()?.webContents.send('request-rating', {
|
||||
id,
|
||||
rating,
|
||||
serverId: currentState.song.serverId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'repeat': {
|
||||
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
|
||||
break;
|
||||
|
|
@ -450,28 +469,6 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case 'favorite': {
|
||||
const { favorite, id } = json;
|
||||
if (id && id === currentState.song?.id) {
|
||||
getMainWindow()?.webContents.send('request-favorite', {
|
||||
favorite,
|
||||
id,
|
||||
serverId: currentState.song.serverId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rating': {
|
||||
const { rating, id } = json;
|
||||
if (id && id === currentState.song?.id) {
|
||||
getMainWindow()?.webContents.send('request-rating', {
|
||||
id,
|
||||
rating,
|
||||
serverId: currentState.song.serverId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'position': {
|
||||
const { position } = json;
|
||||
if (mprisPlayer) {
|
||||
|
|
@ -631,12 +628,7 @@ ipcMain.on('update-volume', (_event, volume: number) => {
|
|||
|
||||
if (mprisPlayer) {
|
||||
mprisPlayer.on('loopStatus', (event: string) => {
|
||||
const repeat =
|
||||
event === 'Playlist'
|
||||
? PlayerRepeat.ALL
|
||||
: event === 'Track'
|
||||
? PlayerRepeat.ONE
|
||||
: PlayerRepeat.NONE;
|
||||
const repeat = event === 'Playlist' ? 'all' : event === 'Track' ? 'one' : 'none';
|
||||
|
||||
currentState.repeat = repeat;
|
||||
broadcast({ data: repeat, event: 'repeat' });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { TitleTheme } from '/@/renderer/types';
|
||||
|
||||
import { ipcMain, nativeTheme, safeStorage } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import type { TitleTheme } from '/@/renderer/types';
|
||||
|
||||
export const store = new Store();
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
|
|||
store.set(`${data.property}`, data.value);
|
||||
});
|
||||
|
||||
ipcMain.handle('password-get', (_event, server: string): string | null => {
|
||||
ipcMain.handle('password-get', (_event, server: string): null | string => {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const servers = store.get('server') as Record<string, string> | undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import './core';
|
||||
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
require(`./${process.platform}`);
|
||||
// require(`./${process.platform}`)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { ipcMain } from 'electron';
|
||||
import Player from 'mpris-service';
|
||||
|
||||
import { PlayerRepeat, PlayerStatus } from '../../../renderer/types';
|
||||
import { getMainWindow } from '../../main';
|
||||
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
|
||||
const mprisPlayer = Player({
|
||||
|
|
@ -124,8 +126,8 @@ ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
|
|||
|
||||
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
|
||||
[PlayerRepeat.ALL]: 'Playlist',
|
||||
[PlayerRepeat.ONE]: 'Track',
|
||||
[PlayerRepeat.NONE]: 'None',
|
||||
[PlayerRepeat.ONE]: 'Track',
|
||||
};
|
||||
|
||||
ipcMain.on('update-repeat', (_event, arg: PlayerRepeat) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
// Dummy file to satisfy the build system
|
||||
|
||||
export {};
|
||||
|
|
@ -1,51 +1,40 @@
|
|||
/* eslint global-require: off, no-console: off, promise/always-return: off */
|
||||
|
||||
/**
|
||||
* This module executes inside of electron's main process. You can start
|
||||
* electron renderer process from here and communicate with the other processes
|
||||
* through IPC.
|
||||
*
|
||||
* When running `npm run build` or `npm run build:main`, this file is compiled to
|
||||
* `./src/main.js` using webpack. This gives us some performance wins.
|
||||
*/
|
||||
import { access, constants, readFile, writeFile } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import { deflate, inflate } from 'zlib';
|
||||
import { electronApp, is, optimizer } from '@electron-toolkit/utils';
|
||||
import { constants } from 'buffer';
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
shell,
|
||||
ipcMain,
|
||||
BrowserWindowConstructorOptions,
|
||||
globalShortcut,
|
||||
Tray,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
BrowserWindowConstructorOptions,
|
||||
protocol,
|
||||
net,
|
||||
protocol,
|
||||
Rectangle,
|
||||
screen,
|
||||
shell,
|
||||
Tray,
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log/main';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { access, readFile, writeFile } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import { deflate, inflate } from 'zlib';
|
||||
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
import { store } from './features/core/settings/index';
|
||||
import { store } from './features/core/settings';
|
||||
import MenuBuilder from './menu';
|
||||
import {
|
||||
autoUpdaterLogInterface,
|
||||
createLog,
|
||||
hotkeyToElectronAccelerator,
|
||||
isLinux,
|
||||
isMacOS,
|
||||
isWindows,
|
||||
resolveHtmlPath,
|
||||
createLog,
|
||||
autoUpdaterLogInterface,
|
||||
} from './utils';
|
||||
import './features';
|
||||
import type { TitleTheme } from '/@/renderer/types';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
export default class AppUpdater {
|
||||
constructor() {
|
||||
|
|
@ -72,7 +61,7 @@ if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
|
|||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let tray: null | Tray = null;
|
||||
let exitFromTray = false;
|
||||
let forceQuit = false;
|
||||
|
||||
|
|
@ -117,7 +106,7 @@ export const sendToastToRenderer = ({
|
|||
type,
|
||||
}: {
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
type: 'error' | 'info' | 'success' | 'warning';
|
||||
}) => {
|
||||
getMainWindow()?.webContents.send('toast-from-main', {
|
||||
message,
|
||||
|
|
@ -208,7 +197,7 @@ const createTray = () => {
|
|||
tray.setContextMenu(contextMenu);
|
||||
};
|
||||
|
||||
const createWindow = async (first = true) => {
|
||||
async function createWindow(first = true): Promise<void> {
|
||||
if (isDevelopment) {
|
||||
await installExtensions().catch(console.log);
|
||||
}
|
||||
|
|
@ -233,6 +222,7 @@ const createWindow = async (first = true) => {
|
|||
},
|
||||
};
|
||||
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
autoHideMenuBar: true,
|
||||
frame: false,
|
||||
|
|
@ -247,9 +237,8 @@ const createWindow = async (first = true) => {
|
|||
contextIsolation: true,
|
||||
devTools: true,
|
||||
nodeIntegration: true,
|
||||
preload: app.isPackaged
|
||||
? path.join(__dirname, 'preload.js')
|
||||
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: !store.get('ignore_cors'),
|
||||
},
|
||||
width: 1440,
|
||||
|
|
@ -374,11 +363,11 @@ const createWindow = async (first = true) => {
|
|||
enableMediaKeys(mainWindow);
|
||||
}
|
||||
|
||||
mainWindow.loadURL(resolveHtmlPath('index.html'));
|
||||
|
||||
const startWindowMinimized = store.get('window_start_minimized', false) as boolean;
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
// mainWindow.show()
|
||||
|
||||
if (!mainWindow) {
|
||||
throw new Error('"mainWindow" is not defined');
|
||||
}
|
||||
|
|
@ -404,7 +393,7 @@ const createWindow = async (first = true) => {
|
|||
mainWindow = null;
|
||||
});
|
||||
|
||||
let saved = false;
|
||||
const saved = false;
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
store.set('bounds', mainWindow?.getNormalBounds());
|
||||
|
|
@ -484,13 +473,25 @@ const createWindow = async (first = true) => {
|
|||
});
|
||||
|
||||
if (store.get('disable_auto_updates') !== true) {
|
||||
// eslint-disable-next-line
|
||||
new AppUpdater();
|
||||
}
|
||||
|
||||
const theme = store.get('theme') as TitleTheme | undefined;
|
||||
nativeTheme.themeSource = theme || 'dark';
|
||||
};
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||
|
||||
|
|
@ -519,6 +520,8 @@ enum BindingActions {
|
|||
}
|
||||
|
||||
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
|
||||
[BindingActions.GLOBAL_SEARCH]: () => {},
|
||||
[BindingActions.LOCAL_SEARCH]: () => {},
|
||||
[BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),
|
||||
[BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),
|
||||
[BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),
|
||||
|
|
@ -533,16 +536,14 @@ const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
|
|||
[BindingActions.SKIP_FORWARD]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
|
||||
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
|
||||
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
|
||||
[BindingActions.TOGGLE_QUEUE]: () => {},
|
||||
[BindingActions.TOGGLE_REPEAT]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
|
||||
[BindingActions.VOLUME_UP]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-volume-up'),
|
||||
[BindingActions.VOLUME_DOWN]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-volume-down'),
|
||||
[BindingActions.GLOBAL_SEARCH]: () => {},
|
||||
[BindingActions.LOCAL_SEARCH]: () => {},
|
||||
[BindingActions.TOGGLE_QUEUE]: () => {},
|
||||
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
|
||||
[BindingActions.VOLUME_UP]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-volume-up'),
|
||||
};
|
||||
|
||||
ipcMain.on(
|
||||
|
|
@ -585,7 +586,7 @@ ipcMain.on(
|
|||
_event,
|
||||
data: {
|
||||
message: string;
|
||||
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
|
||||
type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning';
|
||||
},
|
||||
) => {
|
||||
createLog(data);
|
||||
|
|
@ -597,7 +598,6 @@ app.on('window-all-closed', () => {
|
|||
// Respect the OSX convention of having the application in memory even
|
||||
// after all windows have been closed
|
||||
if (isMacOS()) {
|
||||
ipcMain.removeHandler('window-clear-cache');
|
||||
mainWindow = null;
|
||||
} else {
|
||||
app.quit();
|
||||
|
|
@ -632,22 +632,22 @@ if (!singleInstance) {
|
|||
|
||||
app.whenReady()
|
||||
.then(() => {
|
||||
protocol.handle('feishin', async (request) => {
|
||||
const filePath = `file://${request.url.slice('feishin://'.length)}`;
|
||||
const response = await net.fetch(filePath);
|
||||
const contentType = response.headers.get('content-type');
|
||||
// protocol.handle('feishin', async (request) => {
|
||||
// const filePath = `file://${request.url.slice('feishin://'.length)}`
|
||||
// const response = await net.fetch(filePath)
|
||||
// const contentType = response.headers.get('content-type')
|
||||
|
||||
if (!contentType || !FONT_HEADERS.includes(contentType)) {
|
||||
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||
// if (!contentType || !FONT_HEADERS.includes(contentType)) {
|
||||
// getMainWindow()?.webContents.send('custom-font-error', filePath)
|
||||
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
});
|
||||
}
|
||||
// return new Response(null, {
|
||||
// status: 403,
|
||||
// statusText: 'Forbidden'
|
||||
// })
|
||||
// }
|
||||
|
||||
return response;
|
||||
});
|
||||
// return response
|
||||
// })
|
||||
|
||||
createWindow();
|
||||
if (store.get('window_enable_tray', true)) {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron';
|
||||
|
||||
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
|
||||
selector?: string;
|
||||
|
|
@ -12,37 +12,6 @@ export default class MenuBuilder {
|
|||
this.mainWindow = mainWindow;
|
||||
}
|
||||
|
||||
buildMenu(): Menu {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
|
||||
this.setupDevelopmentEnvironment();
|
||||
}
|
||||
|
||||
const template =
|
||||
process.platform === 'darwin'
|
||||
? this.buildDarwinTemplate()
|
||||
: this.buildDefaultTemplate();
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
setupDevelopmentEnvironment(): void {
|
||||
this.mainWindow.webContents.on('context-menu', (_, props) => {
|
||||
const { x, y } = props;
|
||||
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
click: () => {
|
||||
this.mainWindow.webContents.inspectElement(x, y);
|
||||
},
|
||||
label: 'Inspect element',
|
||||
},
|
||||
]).popup({ window: this.mainWindow });
|
||||
});
|
||||
}
|
||||
|
||||
buildDarwinTemplate(): MenuItemConstructorOptions[] {
|
||||
const subMenuAbout: DarwinMenuItemConstructorOptions = {
|
||||
label: 'Electron',
|
||||
|
|
@ -276,4 +245,35 @@ export default class MenuBuilder {
|
|||
|
||||
return templateDefault;
|
||||
}
|
||||
|
||||
buildMenu(): Menu {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
|
||||
this.setupDevelopmentEnvironment();
|
||||
}
|
||||
|
||||
const template =
|
||||
process.platform === 'darwin'
|
||||
? this.buildDarwinTemplate()
|
||||
: this.buildDefaultTemplate();
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
setupDevelopmentEnvironment(): void {
|
||||
this.mainWindow.webContents.on('context-menu', (_, props) => {
|
||||
const { x, y } = props;
|
||||
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
click: () => {
|
||||
this.mainWindow.webContents.inspectElement(x, y);
|
||||
},
|
||||
label: 'Inspect element',
|
||||
},
|
||||
]).popup({ window: this.mainWindow });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
import { contextBridge } from 'electron';
|
||||
import { browser } from './preload/browser';
|
||||
import { discordRpc } from './preload/discord-rpc';
|
||||
import { ipc } from './preload/ipc';
|
||||
import { localSettings } from './preload/local-settings';
|
||||
import { lyrics } from './preload/lyrics';
|
||||
import { mpris } from './preload/mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
||||
import { remote } from './preload/remote';
|
||||
import { utils } from './preload/utils';
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
browser,
|
||||
discordRpc,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
mpris,
|
||||
mpvPlayer,
|
||||
mpvPlayerListener,
|
||||
remote,
|
||||
utils,
|
||||
});
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
/* eslint import/prefer-default-export: off, import/no-mutable-exports: off */
|
||||
import log from 'electron-log/main';
|
||||
import path from 'path';
|
||||
import process from 'process';
|
||||
import { URL } from 'url';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
export let resolveHtmlPath: (htmlFileName: string) => string;
|
||||
|
||||
|
|
@ -76,7 +75,7 @@ const logColor = {
|
|||
|
||||
export const createLog = (data: {
|
||||
message: string;
|
||||
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
|
||||
type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning';
|
||||
}) => {
|
||||
logMethod[data.type](`%c${data.message}`, `color: ${logColor[data.type]}`);
|
||||
};
|
||||
|
|
|
|||
10
src/preload/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ElectronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
import { PreloadApi } from './index';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: PreloadApi;
|
||||
electron: ElectronAPI;
|
||||
}
|
||||
}
|
||||
45
src/preload/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { electronAPI } from '@electron-toolkit/preload';
|
||||
import { contextBridge } from 'electron';
|
||||
|
||||
import { browser } from './browser';
|
||||
import { discordRpc } from './discord-rpc';
|
||||
import { ipc } from './ipc';
|
||||
import { localSettings } from './local-settings';
|
||||
import { lyrics } from './lyrics';
|
||||
import { mpris } from './mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
|
||||
import { remote } from './remote';
|
||||
import { utils } from './utils';
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
browser,
|
||||
discordRpc,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
mpris,
|
||||
mpvPlayer,
|
||||
mpvPlayerListener,
|
||||
remote,
|
||||
utils,
|
||||
};
|
||||
|
||||
export type PreloadApi = typeof api;
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('api', api);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api;
|
||||
}
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
|
||||
import { ipcRenderer, IpcRendererEvent, webFrame } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { toServerType, type TitleTheme } from '/@/renderer/types';
|
||||
|
||||
const store = new Store();
|
||||
|
||||
const set = (
|
||||
property: string,
|
||||
value: string | Record<string, unknown> | boolean | string[] | undefined,
|
||||
value: boolean | Record<string, unknown> | string | string[] | undefined,
|
||||
) => {
|
||||
if (value === undefined) {
|
||||
store.delete(property);
|
||||
|
|
@ -32,7 +31,7 @@ const disableMediaKeys = () => {
|
|||
ipcRenderer.send('global-media-keys-disable');
|
||||
};
|
||||
|
||||
const passwordGet = async (server: string): Promise<string | null> => {
|
||||
const passwordGet = async (server: string): Promise<null | string> => {
|
||||
return ipcRenderer.invoke('password-get', server);
|
||||
};
|
||||
|
||||
|
|
@ -56,6 +55,19 @@ const themeSet = (theme: TitleTheme): void => {
|
|||
ipcRenderer.send('theme-set', theme);
|
||||
};
|
||||
|
||||
export const toServerType = (value?: string): null | string => {
|
||||
switch (value?.toLowerCase()) {
|
||||
case 'jellyfin':
|
||||
return 'jellyfin';
|
||||
case 'navidrome':
|
||||
return 'navidrome';
|
||||
case 'subsonic':
|
||||
return 'subsonic';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const SERVER_TYPE = toServerType(process.env.SERVER_TYPE);
|
||||
|
||||
const env = {
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import {
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricGetQuery,
|
||||
LyricSearchQuery,
|
||||
LyricSource,
|
||||
QueueSong,
|
||||
} from '/@/renderer/api/types';
|
||||
} from '../main/features/core/lyrics';
|
||||
|
||||
const getRemoteLyricsBySong = (song: QueueSong) => {
|
||||
const result = ipcRenderer.invoke('lyric-by-song', song);
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||
import type { PlayerRepeat } from '/@/renderer/types';
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
|
||||
const updatePosition = (timeSec: number) => {
|
||||
ipcRenderer.send('mpris-update-position', timeSec);
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
return ipcRenderer.invoke('player-initialize', data);
|
||||
|
|
@ -187,8 +186,8 @@ export const mpvPlayerListener = {
|
|||
rendererNext,
|
||||
rendererPause,
|
||||
rendererPlay,
|
||||
rendererPlayPause,
|
||||
rendererPlayerFallback,
|
||||
rendererPlayPause,
|
||||
rendererPrevious,
|
||||
rendererQuit,
|
||||
rendererSkipBackward,
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
import { PlayerStatus } from '/@/renderer/types';
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
|
||||
const requestFavorite = (
|
||||
cb: (
|
||||
|
|
@ -29,12 +27,12 @@ const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) =
|
|||
ipcRenderer.on('request-volume', cb);
|
||||
};
|
||||
|
||||
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
|
||||
const setRemoteEnabled = (enabled: boolean): Promise<null | string> => {
|
||||
const result = ipcRenderer.invoke('remote-enable', enabled);
|
||||
return result;
|
||||
};
|
||||
|
||||
const setRemotePort = (port: number): Promise<string | null> => {
|
||||
const setRemotePort = (port: number): Promise<null | string> => {
|
||||
const result = ipcRenderer.invoke('remote-port', port);
|
||||
return result;
|
||||
};
|
||||
|
|
@ -56,7 +54,7 @@ const updateSetting = (
|
|||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string | null> => {
|
||||
): Promise<null | string> => {
|
||||
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
|
||||
};
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||
import { isMacOS, isWindows, isLinux } from '../utils';
|
||||
import { PlayerState } from '/@/renderer/store';
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
|
||||
import { isLinux, isMacOS, isWindows } from '../main/utils';
|
||||
|
||||
const saveQueue = (data: Record<string, any>) => {
|
||||
ipcRenderer.send('player-save-queue', data);
|
||||
|
|
@ -18,7 +18,7 @@ const onSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
|
|||
ipcRenderer.on('renderer-save-queue', cb);
|
||||
};
|
||||
|
||||
const onRestoreQueue = (cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void) => {
|
||||
const onRestoreQueue = (cb: (event: IpcRendererEvent, data: Partial<any>) => void) => {
|
||||
ipcRenderer.on('renderer-restore-queue', cb);
|
||||
};
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number
|
|||
const mainMessageListener = (
|
||||
cb: (
|
||||
event: IpcRendererEvent,
|
||||
data: { message: string; type: 'success' | 'error' | 'warning' | 'info' },
|
||||
data: { message: string; type: 'error' | 'info' | 'success' | 'warning' },
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on('toast-from-main', cb);
|
||||
|
|
@ -40,7 +40,7 @@ const logger = (
|
|||
event: IpcRendererEvent,
|
||||
data: {
|
||||
message: string;
|
||||
type: 'debug' | 'verbose' | 'error' | 'warning' | 'info';
|
||||
type: 'debug' | 'error' | 'info' | 'verbose' | 'warning';
|
||||
},
|
||||
) => void,
|
||||
) => {
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import type { ServerType, ControllerEndpoint, AuthenticationResponse } from '/@/renderer/api/types';
|
||||
import type { AuthenticationResponse, ControllerEndpoint, ServerType } from '/@/renderer/api/types';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
|
||||
type ApiController = {
|
||||
jellyfin: ControllerEndpoint;
|
||||
|
|
|
|||
|
|
@ -1,97 +1,56 @@
|
|||
export type JFBasePaginatedResponse = {
|
||||
StartIndex: number;
|
||||
TotalRecordCount: number;
|
||||
};
|
||||
|
||||
export interface JFMusicFolderListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFMusicFolder[];
|
||||
export enum JFAlbumArtistListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export type JFMusicFolderList = JFMusicFolder[];
|
||||
|
||||
export interface JFGenreListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFGenre[];
|
||||
export enum JFAlbumListSort {
|
||||
ALBUM_ARTIST = 'AlbumArtist,SortName',
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
CRITIC_RATING = 'CriticRating,SortName',
|
||||
NAME = 'SortName',
|
||||
PLAY_COUNT = 'PlayCount',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
|
||||
}
|
||||
|
||||
export type JFGenreList = JFGenreListResponse;
|
||||
export enum JFArtistListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export enum JFCollectionType {
|
||||
MUSIC = 'music',
|
||||
PLAYLISTS = 'playlists',
|
||||
}
|
||||
|
||||
export enum JFExternalType {
|
||||
MUSICBRAINZ = 'MusicBrainz',
|
||||
THEAUDIODB = 'TheAudioDb',
|
||||
}
|
||||
|
||||
export enum JFGenreListSort {
|
||||
NAME = 'SortName',
|
||||
}
|
||||
|
||||
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
|
||||
|
||||
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
|
||||
|
||||
export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
export enum JFImageType {
|
||||
LOGO = 'Logo',
|
||||
PRIMARY = 'Primary',
|
||||
}
|
||||
|
||||
export type JFAlbumArtistList = {
|
||||
items: JFAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export interface JFArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
export enum JFItemType {
|
||||
AUDIO = 'Audio',
|
||||
MUSICALBUM = 'MusicAlbum',
|
||||
}
|
||||
|
||||
export type JFArtistList = JFArtistListResponse;
|
||||
|
||||
export interface JFAlbumListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbum[];
|
||||
}
|
||||
|
||||
export type JFAlbumList = {
|
||||
items: JFAlbum[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type JFAlbumDetailResponse = JFAlbum;
|
||||
|
||||
export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] };
|
||||
|
||||
export interface JFSongListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFSong[];
|
||||
}
|
||||
|
||||
export type JFSongList = {
|
||||
items: JFSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type JFAddToPlaylistResponse = {
|
||||
added: number;
|
||||
};
|
||||
|
||||
export type JFAddToPlaylistParams = {
|
||||
ids: string[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type JFAddToPlaylist = null;
|
||||
|
||||
export type JFRemoveFromPlaylistResponse = null;
|
||||
|
||||
export type JFRemoveFromPlaylistParams = {
|
||||
entryIds: string[];
|
||||
};
|
||||
|
||||
export type JFRemoveFromPlaylist = null;
|
||||
|
||||
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFPlaylist[];
|
||||
}
|
||||
|
||||
export type JFPlaylistList = {
|
||||
items: JFPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export enum JFPlaylistListSort {
|
||||
ALBUM_ARTIST = 'AlbumArtist,SortName',
|
||||
DURATION = 'Runtime',
|
||||
|
|
@ -100,124 +59,34 @@ export enum JFPlaylistListSort {
|
|||
SONG_COUNT = 'ChildCount',
|
||||
}
|
||||
|
||||
export type JFPlaylistDetailResponse = JFPlaylist;
|
||||
export enum JFSongListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
|
||||
ARTIST = 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'Name',
|
||||
PLAY_COUNT = 'PlayCount,SortName',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RECENTLY_PLAYED = 'DatePlayed,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] };
|
||||
export enum JFSortOrder {
|
||||
ASC = 'Ascending',
|
||||
DESC = 'Descending',
|
||||
}
|
||||
|
||||
export type JFPlaylist = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
ChildCount?: number;
|
||||
DateCreated: string;
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: UserData;
|
||||
export type JFAddToPlaylist = null;
|
||||
|
||||
export type JFAddToPlaylistParams = {
|
||||
ids: string[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type JFRequestParams = {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
enableImageTypes?: string;
|
||||
enableTotalRecordCount?: boolean;
|
||||
enableUserData?: boolean;
|
||||
excludeItemTypes?: string;
|
||||
fields?: string;
|
||||
imageTypeLimit?: number;
|
||||
includeItemTypes?: string;
|
||||
isFavorite?: boolean;
|
||||
limit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'Ascending' | 'Descending';
|
||||
startIndex?: number;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type JFMusicFolder = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
CollectionType: string;
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: UserData;
|
||||
};
|
||||
|
||||
export type JFGenre = {
|
||||
BackdropImageTags: any[];
|
||||
ChannelId: null;
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: ImageTags;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
};
|
||||
|
||||
export type JFAlbumArtist = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: ImageTags;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlayCount: number;
|
||||
PlaybackPositionTicks: number;
|
||||
Played: boolean;
|
||||
};
|
||||
} & {
|
||||
similarArtists: {
|
||||
items: JFAlbumArtist[];
|
||||
};
|
||||
};
|
||||
|
||||
export type JFArtist = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: string[];
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
export type JFAddToPlaylistResponse = {
|
||||
added: number;
|
||||
};
|
||||
|
||||
export type JFAlbum = {
|
||||
|
|
@ -251,6 +120,244 @@ export type JFAlbum = {
|
|||
songs?: JFSong[];
|
||||
};
|
||||
|
||||
export type JFAlbumArtist = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: ImageTags;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlaybackPositionTicks: number;
|
||||
PlayCount: number;
|
||||
Played: boolean;
|
||||
};
|
||||
} & {
|
||||
similarArtists: {
|
||||
items: JFAlbumArtist[];
|
||||
};
|
||||
};
|
||||
|
||||
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
|
||||
|
||||
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
|
||||
|
||||
export type JFAlbumArtistList = {
|
||||
items: JFAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type JFAlbumArtistListParams = JFBaseParams &
|
||||
JFPaginationParams & {
|
||||
filters?: string;
|
||||
genres?: string;
|
||||
sortBy?: JFAlbumArtistListSort;
|
||||
years?: string;
|
||||
};
|
||||
|
||||
export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] };
|
||||
|
||||
export type JFAlbumDetailResponse = JFAlbum;
|
||||
|
||||
export type JFAlbumList = {
|
||||
items: JFAlbum[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type JFAlbumListParams = JFBaseParams &
|
||||
JFPaginationParams & {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
includeItemTypes: 'MusicAlbum';
|
||||
isFavorite?: boolean;
|
||||
searchTerm?: string;
|
||||
sortBy?: JFAlbumListSort;
|
||||
tags?: string;
|
||||
years?: string;
|
||||
};
|
||||
|
||||
export interface JFAlbumListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbum[];
|
||||
}
|
||||
|
||||
export type JFArtist = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: string[];
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
};
|
||||
|
||||
export type JFArtistList = JFArtistListResponse;
|
||||
|
||||
export type JFArtistListParams = JFBaseParams &
|
||||
JFPaginationParams & {
|
||||
filters?: string;
|
||||
genres?: string;
|
||||
sortBy?: JFArtistListSort;
|
||||
years?: string;
|
||||
};
|
||||
|
||||
export interface JFArtistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export interface JFAuthenticate {
|
||||
AccessToken: string;
|
||||
ServerId: string;
|
||||
SessionInfo: SessionInfo;
|
||||
User: User;
|
||||
}
|
||||
|
||||
export type JFBasePaginatedResponse = {
|
||||
StartIndex: number;
|
||||
TotalRecordCount: number;
|
||||
};
|
||||
|
||||
export type JFCreatePlaylist = JFCreatePlaylistResponse;
|
||||
|
||||
export type JFCreatePlaylistResponse = {
|
||||
Id: string;
|
||||
};
|
||||
|
||||
export type JFGenericItem = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
};
|
||||
|
||||
export type JFGenre = {
|
||||
BackdropImageTags: any[];
|
||||
ChannelId: null;
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: ImageTags;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
};
|
||||
|
||||
export type JFGenreList = JFGenreListResponse;
|
||||
|
||||
export interface JFGenreListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFGenre[];
|
||||
}
|
||||
|
||||
export type JFMusicFolder = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
CollectionType: string;
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: UserData;
|
||||
};
|
||||
|
||||
export type JFMusicFolderList = JFMusicFolder[];
|
||||
|
||||
export interface JFMusicFolderListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFMusicFolder[];
|
||||
}
|
||||
|
||||
export type JFPlaylist = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
ChildCount?: number;
|
||||
DateCreated: string;
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: UserData;
|
||||
};
|
||||
|
||||
export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] };
|
||||
|
||||
export type JFPlaylistDetailResponse = JFPlaylist;
|
||||
|
||||
export type JFPlaylistList = {
|
||||
items: JFPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFPlaylist[];
|
||||
}
|
||||
|
||||
export type JFRemoveFromPlaylist = null;
|
||||
|
||||
export type JFRemoveFromPlaylistParams = {
|
||||
entryIds: string[];
|
||||
};
|
||||
|
||||
export type JFRemoveFromPlaylistResponse = null;
|
||||
|
||||
export type JFRequestParams = {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
enableImageTypes?: string;
|
||||
enableTotalRecordCount?: boolean;
|
||||
enableUserData?: boolean;
|
||||
excludeItemTypes?: string;
|
||||
fields?: string;
|
||||
imageTypeLimit?: number;
|
||||
includeItemTypes?: string;
|
||||
isFavorite?: boolean;
|
||||
limit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'Ascending' | 'Descending';
|
||||
startIndex?: number;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type JFSong = {
|
||||
Album: string;
|
||||
AlbumArtist: string;
|
||||
|
|
@ -285,23 +392,56 @@ export type JFSong = {
|
|||
UserData?: UserData;
|
||||
};
|
||||
|
||||
type ImageBlurHashes = {
|
||||
Backdrop?: any;
|
||||
Logo?: any;
|
||||
Primary?: any;
|
||||
export type JFSongList = {
|
||||
items: JFSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
type ImageTags = {
|
||||
Logo?: string;
|
||||
Primary?: string;
|
||||
export type JFSongListParams = JFBaseParams &
|
||||
JFPaginationParams & {
|
||||
albumArtistIds?: string;
|
||||
albumIds?: string;
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
ids?: string;
|
||||
includeItemTypes: 'Audio';
|
||||
searchTerm?: string;
|
||||
sortBy?: JFSongListSort;
|
||||
years?: string;
|
||||
};
|
||||
|
||||
export interface JFSongListResponse extends JFBasePaginatedResponse {
|
||||
Items: JFSong[];
|
||||
}
|
||||
|
||||
type Capabilities = {
|
||||
PlayableMediaTypes: any[];
|
||||
SupportedCommands: any[];
|
||||
SupportsContentUploading: boolean;
|
||||
SupportsMediaControl: boolean;
|
||||
SupportsPersistentIdentifier: boolean;
|
||||
SupportsSync: boolean;
|
||||
};
|
||||
|
||||
type UserData = {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlayCount: number;
|
||||
PlaybackPositionTicks: number;
|
||||
Played: boolean;
|
||||
type Configuration = {
|
||||
DisplayCollectionsView: boolean;
|
||||
DisplayMissingEpisodes: boolean;
|
||||
EnableLocalPassword: boolean;
|
||||
EnableNextEpisodeAutoPlay: boolean;
|
||||
GroupedFolders: any[];
|
||||
HidePlayedInLatest: boolean;
|
||||
LatestItemsExcludes: any[];
|
||||
MyMediaExcludes: any[];
|
||||
OrderedViews: any[];
|
||||
PlayDefaultAudioTrack: boolean;
|
||||
RememberAudioSelections: boolean;
|
||||
RememberSubtitleSelections: boolean;
|
||||
SubtitleLanguagePreference: string;
|
||||
SubtitleMode: string;
|
||||
};
|
||||
|
||||
type ExternalURL = {
|
||||
|
|
@ -314,9 +454,32 @@ type GenreItem = {
|
|||
Name: string;
|
||||
};
|
||||
|
||||
export type JFGenericItem = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
type ImageBlurHashes = {
|
||||
Backdrop?: any;
|
||||
Logo?: any;
|
||||
Primary?: any;
|
||||
};
|
||||
|
||||
type ImageTags = {
|
||||
Logo?: string;
|
||||
Primary?: string;
|
||||
};
|
||||
|
||||
type JFBaseParams = {
|
||||
enableImageTypes?: JFImageType[];
|
||||
fields?: string;
|
||||
imageTypeLimit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
type JFPaginationParams = {
|
||||
limit?: number;
|
||||
nameStartsWith?: string;
|
||||
sortOrder?: JFSortOrder;
|
||||
startIndex?: number;
|
||||
};
|
||||
|
||||
type MediaSources = {
|
||||
|
|
@ -380,32 +543,53 @@ type MediaStream = {
|
|||
Width?: number;
|
||||
};
|
||||
|
||||
export enum JFExternalType {
|
||||
MUSICBRAINZ = 'MusicBrainz',
|
||||
THEAUDIODB = 'TheAudioDb',
|
||||
}
|
||||
type PlayState = {
|
||||
CanSeek: boolean;
|
||||
IsMuted: boolean;
|
||||
IsPaused: boolean;
|
||||
RepeatMode: string;
|
||||
};
|
||||
|
||||
export enum JFImageType {
|
||||
LOGO = 'Logo',
|
||||
PRIMARY = 'Primary',
|
||||
}
|
||||
|
||||
export enum JFItemType {
|
||||
AUDIO = 'Audio',
|
||||
MUSICALBUM = 'MusicAlbum',
|
||||
}
|
||||
|
||||
export enum JFCollectionType {
|
||||
MUSIC = 'music',
|
||||
PLAYLISTS = 'playlists',
|
||||
}
|
||||
|
||||
export interface JFAuthenticate {
|
||||
AccessToken: string;
|
||||
ServerId: string;
|
||||
SessionInfo: SessionInfo;
|
||||
User: User;
|
||||
}
|
||||
type Policy = {
|
||||
AccessSchedules: any[];
|
||||
AuthenticationProviderId: string;
|
||||
BlockedChannels: any[];
|
||||
BlockedMediaFolders: any[];
|
||||
BlockedTags: any[];
|
||||
BlockUnratedItems: any[];
|
||||
EnableAllChannels: boolean;
|
||||
EnableAllDevices: boolean;
|
||||
EnableAllFolders: boolean;
|
||||
EnableAudioPlaybackTranscoding: boolean;
|
||||
EnableContentDeletion: boolean;
|
||||
EnableContentDeletionFromFolders: any[];
|
||||
EnableContentDownloading: boolean;
|
||||
EnabledChannels: any[];
|
||||
EnabledDevices: any[];
|
||||
EnabledFolders: any[];
|
||||
EnableLiveTvAccess: boolean;
|
||||
EnableLiveTvManagement: boolean;
|
||||
EnableMediaConversion: boolean;
|
||||
EnableMediaPlayback: boolean;
|
||||
EnablePlaybackRemuxing: boolean;
|
||||
EnablePublicSharing: boolean;
|
||||
EnableRemoteAccess: boolean;
|
||||
EnableRemoteControlOfOtherUsers: boolean;
|
||||
EnableSharedDeviceControl: boolean;
|
||||
EnableSyncTranscoding: boolean;
|
||||
EnableUserPreferenceAccess: boolean;
|
||||
EnableVideoPlaybackTranscoding: boolean;
|
||||
ForceRemoteSourceTranscoding: boolean;
|
||||
InvalidLoginAttemptCount: number;
|
||||
IsAdministrator: boolean;
|
||||
IsDisabled: boolean;
|
||||
IsHidden: boolean;
|
||||
LoginAttemptsBeforeLockout: number;
|
||||
MaxActiveSessions: number;
|
||||
PasswordResetProviderId: string;
|
||||
RemoteClientBitrateLimit: number;
|
||||
SyncPlayAccess: string;
|
||||
};
|
||||
|
||||
type SessionInfo = {
|
||||
AdditionalUsers: any[];
|
||||
|
|
@ -421,8 +605,8 @@ type SessionInfo = {
|
|||
LastPlaybackCheckIn: string;
|
||||
NowPlayingQueue: any[];
|
||||
NowPlayingQueueFullItems: any[];
|
||||
PlayState: PlayState;
|
||||
PlayableMediaTypes: any[];
|
||||
PlayState: PlayState;
|
||||
RemoteEndPoint: string;
|
||||
ServerId: string;
|
||||
SupportedCommands: any[];
|
||||
|
|
@ -432,22 +616,6 @@ type SessionInfo = {
|
|||
UserName: string;
|
||||
};
|
||||
|
||||
type Capabilities = {
|
||||
PlayableMediaTypes: any[];
|
||||
SupportedCommands: any[];
|
||||
SupportsContentUploading: boolean;
|
||||
SupportsMediaControl: boolean;
|
||||
SupportsPersistentIdentifier: boolean;
|
||||
SupportsSync: boolean;
|
||||
};
|
||||
|
||||
type PlayState = {
|
||||
CanSeek: boolean;
|
||||
IsMuted: boolean;
|
||||
IsPaused: boolean;
|
||||
RepeatMode: string;
|
||||
};
|
||||
|
||||
type User = {
|
||||
Configuration: Configuration;
|
||||
EnableAutoLogin: boolean;
|
||||
|
|
@ -462,178 +630,10 @@ type User = {
|
|||
ServerId: string;
|
||||
};
|
||||
|
||||
type Configuration = {
|
||||
DisplayCollectionsView: boolean;
|
||||
DisplayMissingEpisodes: boolean;
|
||||
EnableLocalPassword: boolean;
|
||||
EnableNextEpisodeAutoPlay: boolean;
|
||||
GroupedFolders: any[];
|
||||
HidePlayedInLatest: boolean;
|
||||
LatestItemsExcludes: any[];
|
||||
MyMediaExcludes: any[];
|
||||
OrderedViews: any[];
|
||||
PlayDefaultAudioTrack: boolean;
|
||||
RememberAudioSelections: boolean;
|
||||
RememberSubtitleSelections: boolean;
|
||||
SubtitleLanguagePreference: string;
|
||||
SubtitleMode: string;
|
||||
type UserData = {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlaybackPositionTicks: number;
|
||||
PlayCount: number;
|
||||
Played: boolean;
|
||||
};
|
||||
|
||||
type Policy = {
|
||||
AccessSchedules: any[];
|
||||
AuthenticationProviderId: string;
|
||||
BlockUnratedItems: any[];
|
||||
BlockedChannels: any[];
|
||||
BlockedMediaFolders: any[];
|
||||
BlockedTags: any[];
|
||||
EnableAllChannels: boolean;
|
||||
EnableAllDevices: boolean;
|
||||
EnableAllFolders: boolean;
|
||||
EnableAudioPlaybackTranscoding: boolean;
|
||||
EnableContentDeletion: boolean;
|
||||
EnableContentDeletionFromFolders: any[];
|
||||
EnableContentDownloading: boolean;
|
||||
EnableLiveTvAccess: boolean;
|
||||
EnableLiveTvManagement: boolean;
|
||||
EnableMediaConversion: boolean;
|
||||
EnableMediaPlayback: boolean;
|
||||
EnablePlaybackRemuxing: boolean;
|
||||
EnablePublicSharing: boolean;
|
||||
EnableRemoteAccess: boolean;
|
||||
EnableRemoteControlOfOtherUsers: boolean;
|
||||
EnableSharedDeviceControl: boolean;
|
||||
EnableSyncTranscoding: boolean;
|
||||
EnableUserPreferenceAccess: boolean;
|
||||
EnableVideoPlaybackTranscoding: boolean;
|
||||
EnabledChannels: any[];
|
||||
EnabledDevices: any[];
|
||||
EnabledFolders: any[];
|
||||
ForceRemoteSourceTranscoding: boolean;
|
||||
InvalidLoginAttemptCount: number;
|
||||
IsAdministrator: boolean;
|
||||
IsDisabled: boolean;
|
||||
IsHidden: boolean;
|
||||
LoginAttemptsBeforeLockout: number;
|
||||
MaxActiveSessions: number;
|
||||
PasswordResetProviderId: string;
|
||||
RemoteClientBitrateLimit: number;
|
||||
SyncPlayAccess: string;
|
||||
};
|
||||
|
||||
type JFBaseParams = {
|
||||
enableImageTypes?: JFImageType[];
|
||||
fields?: string;
|
||||
imageTypeLimit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
type JFPaginationParams = {
|
||||
limit?: number;
|
||||
nameStartsWith?: string;
|
||||
sortOrder?: JFSortOrder;
|
||||
startIndex?: number;
|
||||
};
|
||||
|
||||
export enum JFSortOrder {
|
||||
ASC = 'Ascending',
|
||||
DESC = 'Descending',
|
||||
}
|
||||
|
||||
export enum JFAlbumListSort {
|
||||
ALBUM_ARTIST = 'AlbumArtist,SortName',
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
CRITIC_RATING = 'CriticRating,SortName',
|
||||
NAME = 'SortName',
|
||||
PLAY_COUNT = 'PlayCount',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
|
||||
}
|
||||
|
||||
export type JFAlbumListParams = {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
includeItemTypes: 'MusicAlbum';
|
||||
isFavorite?: boolean;
|
||||
searchTerm?: string;
|
||||
sortBy?: JFAlbumListSort;
|
||||
tags?: string;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export enum JFSongListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
|
||||
ARTIST = 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'Name',
|
||||
PLAY_COUNT = 'PlayCount,SortName',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RECENTLY_PLAYED = 'DatePlayed,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export type JFSongListParams = {
|
||||
albumArtistIds?: string;
|
||||
albumIds?: string;
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
ids?: string;
|
||||
includeItemTypes: 'Audio';
|
||||
searchTerm?: string;
|
||||
sortBy?: JFSongListSort;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export enum JFAlbumArtistListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export type JFAlbumArtistListParams = {
|
||||
filters?: string;
|
||||
genres?: string;
|
||||
sortBy?: JFAlbumArtistListSort;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export enum JFArtistListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'SortName,Name',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export type JFArtistListParams = {
|
||||
filters?: string;
|
||||
genres?: string;
|
||||
sortBy?: JFArtistListSort;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export type JFCreatePlaylistResponse = {
|
||||
Id: string;
|
||||
};
|
||||
|
||||
export type JFCreatePlaylist = JFCreatePlaylistResponse;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useAuthStore } from '/@/renderer/store';
|
||||
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/api/types';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import qs from 'qs';
|
||||
import { z } from 'zod';
|
||||
import { authenticationFailure, getClientType } from '/@/renderer/api/utils';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
import packageJson from '../../../../package.json';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { authenticationFailure, getClientType } from '/@/renderer/api/utils';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
|
|
@ -356,14 +358,14 @@ export const createAuthHeader = (): string => {
|
|||
};
|
||||
|
||||
export const jfApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
server: null | ServerListItem;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
const { server, signal, url } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
api: async ({ body, headers, method, path }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
|
||||
|
|
@ -395,7 +397,7 @@ export const jfApiClient = (args: {
|
|||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
} catch (e: any | AxiosError | Error) {
|
||||
if (isAxiosError(e)) {
|
||||
if (e.code === 'ERR_NETWORK') {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
import chunk from 'lodash/chunk';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import {
|
||||
albumArtistListSortMap,
|
||||
sortOrderMap,
|
||||
albumListSortMap,
|
||||
songListSortMap,
|
||||
playlistListSortMap,
|
||||
genreListSortMap,
|
||||
Song,
|
||||
Played,
|
||||
ControllerEndpoint,
|
||||
genreListSortMap,
|
||||
LibraryItem,
|
||||
Played,
|
||||
playlistListSortMap,
|
||||
Song,
|
||||
songListSortMap,
|
||||
sortOrderMap,
|
||||
} from '/@/renderer/api/types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import { z } from 'zod';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
|
||||
import chunk from 'lodash/chunk';
|
||||
import { getFeatures, hasFeature, VersionInfo } from '/@/renderer/api/utils';
|
||||
|
||||
const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
|
|
@ -41,7 +43,7 @@ const VERSION_INFO: VersionInfo = [
|
|||
|
||||
export const JellyfinController: ControllerEndpoint = {
|
||||
addToPlaylist: async (args) => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -89,7 +91,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
createFavorite: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -108,7 +110,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
createPlaylist: async (args) => {
|
||||
const { body, apiClientProps } = args;
|
||||
const { apiClientProps, body } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -132,7 +134,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
deleteFavorite: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -151,7 +153,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
deletePlaylist: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).deletePlaylist({
|
||||
params: {
|
||||
|
|
@ -166,7 +168,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
getAlbumArtistDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -201,7 +203,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
);
|
||||
},
|
||||
getAlbumArtistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
|
|
@ -236,7 +238,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getAlbumDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -274,7 +276,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
);
|
||||
},
|
||||
getAlbumList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -334,7 +336,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getArtistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getArtistList({
|
||||
query: {
|
||||
|
|
@ -404,7 +406,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getLyrics: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -453,7 +455,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getPlaylistDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -477,7 +479,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
return jfNormalize.playlist(res.body, apiClientProps.server);
|
||||
},
|
||||
getPlaylistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -515,7 +517,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getPlaylistSongList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -547,7 +549,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getRandomSongList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -668,7 +670,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
}, []);
|
||||
},
|
||||
getSongDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongDetail({
|
||||
params: {
|
||||
|
|
@ -684,7 +686,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
return jfNormalize.song(res.body, apiClientProps.server, '');
|
||||
},
|
||||
getSongList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -864,7 +866,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getTranscodingUrl: (args) => {
|
||||
const { base, format, bitrate } = args.query;
|
||||
const { base, bitrate, format } = args.query;
|
||||
let url = base.replace('transcodingProtocol=hls', 'transcodingProtocol=http');
|
||||
if (format) {
|
||||
url = url.replace('audioCodec=aac', `audioCodec=${format}`);
|
||||
|
|
@ -892,7 +894,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
}
|
||||
},
|
||||
removeFromPlaylist: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
|
||||
|
||||
|
|
@ -914,7 +916,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
scrobble: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const position = query.position && Math.round(query.position);
|
||||
|
||||
|
|
@ -978,7 +980,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
search: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
@ -1071,7 +1073,7 @@ export const JellyfinController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
updatePlaylist: async (args) => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
|
||||
|
||||
import { JFAlbum, JFGenre, JFMusicFolder, JFPlaylist } from '/@/renderer/api/jellyfin.types';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import {
|
||||
Song,
|
||||
LibraryItem,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
MusicFolder,
|
||||
Genre,
|
||||
LibraryItem,
|
||||
MusicFolder,
|
||||
Playlist,
|
||||
RelatedArtist,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
RelatedArtist,
|
||||
Song,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
const getStreamUrl = (args: {
|
||||
|
|
@ -21,9 +22,9 @@ const getStreamUrl = (args: {
|
|||
eTag?: string;
|
||||
id: string;
|
||||
mediaSourceId?: string;
|
||||
server: ServerListItem | null;
|
||||
server: null | ServerListItem;
|
||||
}) => {
|
||||
const { id, server, deviceId } = args;
|
||||
const { deviceId, id, server } = args;
|
||||
|
||||
return (
|
||||
`${server?.url}/audio` +
|
||||
|
|
@ -122,9 +123,9 @@ const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size:
|
|||
);
|
||||
};
|
||||
|
||||
type AlbumOrSong = z.infer<typeof jfType._response.song> | z.infer<typeof jfType._response.album>;
|
||||
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
|
||||
|
||||
const getPeople = (item: AlbumOrSong): Record<string, RelatedArtist[]> | null => {
|
||||
const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> => {
|
||||
if (item.People) {
|
||||
const participants: Record<string, RelatedArtist[]> = {};
|
||||
|
||||
|
|
@ -151,7 +152,7 @@ const getPeople = (item: AlbumOrSong): Record<string, RelatedArtist[]> | null =>
|
|||
return null;
|
||||
};
|
||||
|
||||
const getTags = (item: AlbumOrSong): Record<string, string[]> | null => {
|
||||
const getTags = (item: AlbumOrSong): null | Record<string, string[]> => {
|
||||
if (item.Tags) {
|
||||
const tags: Record<string, string[]> = {};
|
||||
for (const tag of item.Tags) {
|
||||
|
|
@ -166,7 +167,7 @@ const getTags = (item: AlbumOrSong): Record<string, string[]> | null => {
|
|||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof jfType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
|
|
@ -252,7 +253,7 @@ const normalizeSong = (
|
|||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof jfType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
return {
|
||||
|
|
@ -312,7 +313,7 @@ const normalizeAlbumArtist = (
|
|||
item: z.infer<typeof jfType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
const similarArtists =
|
||||
|
|
@ -361,7 +362,7 @@ const normalizeAlbumArtist = (
|
|||
|
||||
const normalizePlaylist = (
|
||||
item: z.infer<typeof jfType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getPlaylistCoverArtUrl({
|
||||
|
|
@ -445,7 +446,7 @@ const getGenreCoverArtUrl = (args: {
|
|||
);
|
||||
};
|
||||
|
||||
const normalizeGenre = (item: JFGenre, server: ServerListItem | null): Genre => {
|
||||
const normalizeGenre = (item: JFGenre, server: null | ServerListItem): Genre => {
|
||||
return {
|
||||
albumCount: undefined,
|
||||
id: item.Id,
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ const imageBlurHashes = z.object({
|
|||
const userData = z.object({
|
||||
IsFavorite: z.boolean(),
|
||||
Key: z.string(),
|
||||
PlayCount: z.number(),
|
||||
PlaybackPositionTicks: z.number(),
|
||||
PlayCount: z.number(),
|
||||
Played: z.boolean(),
|
||||
});
|
||||
|
||||
|
|
@ -187,13 +187,13 @@ const sessionInfo = z.object({
|
|||
LastPlaybackCheckIn: z.string(),
|
||||
NowPlayingQueue: z.array(z.any()),
|
||||
NowPlayingQueueFullItems: z.array(z.any()),
|
||||
PlayableMediaTypes: z.array(z.any()),
|
||||
PlayState: z.object({
|
||||
CanSeek: z.boolean(),
|
||||
IsMuted: z.boolean(),
|
||||
IsPaused: z.boolean(),
|
||||
RepeatMode: z.string(),
|
||||
}),
|
||||
PlayableMediaTypes: z.array(z.any()),
|
||||
RemoteEndPoint: z.string(),
|
||||
ServerId: z.string(),
|
||||
SupportedCommands: z.array(z.any()),
|
||||
|
|
@ -223,10 +223,10 @@ const configuration = z.object({
|
|||
const policy = z.object({
|
||||
AccessSchedules: z.array(z.any()),
|
||||
AuthenticationProviderId: z.string(),
|
||||
BlockUnratedItems: z.array(z.any()),
|
||||
BlockedChannels: z.array(z.any()),
|
||||
BlockedMediaFolders: z.array(z.any()),
|
||||
BlockedTags: z.array(z.any()),
|
||||
BlockUnratedItems: z.array(z.any()),
|
||||
EnableAllChannels: z.boolean(),
|
||||
EnableAllDevices: z.boolean(),
|
||||
EnableAllFolders: z.boolean(),
|
||||
|
|
@ -234,6 +234,9 @@ const policy = z.object({
|
|||
EnableContentDeletion: z.boolean(),
|
||||
EnableContentDeletionFromFolders: z.array(z.any()),
|
||||
EnableContentDownloading: z.boolean(),
|
||||
EnabledChannels: z.array(z.any()),
|
||||
EnabledDevices: z.array(z.any()),
|
||||
EnabledFolders: z.array(z.any()),
|
||||
EnableLiveTvAccess: z.boolean(),
|
||||
EnableLiveTvManagement: z.boolean(),
|
||||
EnableMediaConversion: z.boolean(),
|
||||
|
|
@ -246,9 +249,6 @@ const policy = z.object({
|
|||
EnableSyncTranscoding: z.boolean(),
|
||||
EnableUserPreferenceAccess: z.boolean(),
|
||||
EnableVideoPlaybackTranscoding: z.boolean(),
|
||||
EnabledChannels: z.array(z.any()),
|
||||
EnabledDevices: z.array(z.any()),
|
||||
EnabledFolders: z.array(z.any()),
|
||||
ForceRemoteSourceTranscoding: z.boolean(),
|
||||
InvalidLoginAttemptCount: z.number(),
|
||||
IsAdministrator: z.boolean(),
|
||||
|
|
@ -414,8 +414,8 @@ const song = z.object({
|
|||
ImageTags: imageTags,
|
||||
IndexNumber: z.number(),
|
||||
IsFolder: z.boolean(),
|
||||
LUFS: z.number().optional(),
|
||||
LocationType: z.string(),
|
||||
LUFS: z.number().optional(),
|
||||
MediaSources: z.array(mediaSources),
|
||||
MediaType: z.string(),
|
||||
Name: z.string(),
|
||||
|
|
@ -654,8 +654,8 @@ const favorite = z.object({
|
|||
Key: z.string(),
|
||||
LastPlayedDate: z.string(),
|
||||
Likes: z.boolean(),
|
||||
PlayCount: z.number(),
|
||||
PlaybackPositionTicks: z.number(),
|
||||
PlayCount: z.number(),
|
||||
Played: z.boolean(),
|
||||
PlayedPercentage: z.number(),
|
||||
Rating: z.number(),
|
||||
|
|
|
|||
|
|
@ -1,30 +1,77 @@
|
|||
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
|
||||
|
||||
export type NDAuthenticate = {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
subsonicSalt: string;
|
||||
subsonicToken: string;
|
||||
token: string;
|
||||
username: string;
|
||||
export enum NDAlbumArtistListSort {
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
FAVORITED = 'starred_at',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RATING = 'rating',
|
||||
SONG_COUNT = 'songCount',
|
||||
}
|
||||
|
||||
export enum NDAlbumListSort {
|
||||
ALBUM_ARTIST = 'album_artist',
|
||||
ARTIST = 'artist',
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'play_count',
|
||||
PLAY_DATE = 'play_date',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recently_added',
|
||||
SONG_COUNT = 'songCount',
|
||||
STARRED = 'starred_at',
|
||||
YEAR = 'max_year',
|
||||
}
|
||||
|
||||
export enum NDGenreListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export enum NDPlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'owner_name',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
}
|
||||
|
||||
export enum NDSongListSort {
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'order_album_artist_name',
|
||||
ALBUM_SONGS = 'album',
|
||||
ARTIST = 'artist',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
COMMENT = 'comment',
|
||||
DURATION = 'duration',
|
||||
FAVORITED = 'starred_at',
|
||||
GENRE = 'genre',
|
||||
ID = 'id',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'playDate',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
TRACK = 'track',
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export enum NDSortOrder {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
|
||||
export type NDAddToPlaylist = null;
|
||||
|
||||
export type NDAddToPlaylistBody = {
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDUser = {
|
||||
createdAt: string;
|
||||
email: string;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
lastAccessAt: string;
|
||||
lastLoginAt: string;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
export type NDGenre = {
|
||||
id: string;
|
||||
name: string;
|
||||
export type NDAddToPlaylistResponse = {
|
||||
added: number;
|
||||
};
|
||||
|
||||
export type NDAlbum = {
|
||||
|
|
@ -61,6 +108,193 @@ export type NDAlbum = {
|
|||
updatedAt: string;
|
||||
} & { songs?: NDSong[] };
|
||||
|
||||
export type NDAlbumArtist = {
|
||||
albumCount: number;
|
||||
biography: string;
|
||||
externalInfoUpdatedAt: string;
|
||||
externalUrl: string;
|
||||
fullText: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
largeImageUrl?: string;
|
||||
mbzArtistId: string;
|
||||
mediumImageUrl?: string;
|
||||
name: string;
|
||||
orderArtistName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
smallImageUrl?: string;
|
||||
songCount: number;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
} & {
|
||||
similarArtists?: SSArtistInfo['similarArtist'];
|
||||
};
|
||||
|
||||
export type NDAlbumArtistDetail = NDAlbumArtist;
|
||||
|
||||
export type NDAlbumArtistDetailResponse = NDAlbumArtist;
|
||||
|
||||
export type NDAlbumArtistList = {
|
||||
items: NDAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDAlbumArtistListParams = NDOrder &
|
||||
NDPagination & {
|
||||
_sort?: NDAlbumArtistListSort;
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
|
||||
export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
|
||||
|
||||
export type NDAlbumDetailResponse = NDAlbum;
|
||||
|
||||
export type NDAlbumList = {
|
||||
items: NDAlbum[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDAlbumListParams = NDOrder &
|
||||
NDPagination & {
|
||||
_sort?: NDAlbumListSort;
|
||||
album_id?: string;
|
||||
artist_id?: string;
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
};
|
||||
|
||||
export type NDAlbumListResponse = NDAlbum[];
|
||||
|
||||
export type NDArtistListResponse = NDAlbumArtist[];
|
||||
|
||||
export type NDAuthenticate = {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
subsonicSalt: string;
|
||||
subsonicToken: string;
|
||||
token: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type NDAuthenticationResponse = NDAuthenticate;
|
||||
|
||||
export type NDCreatePlaylist = NDCreatePlaylistResponse;
|
||||
|
||||
export type NDCreatePlaylistParams = {
|
||||
comment?: string;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
rules?: null | Record<string, any>;
|
||||
};
|
||||
|
||||
export type NDCreatePlaylistResponse = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type NDDeletePlaylist = NDDeletePlaylistResponse;
|
||||
|
||||
export type NDDeletePlaylistParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type NDDeletePlaylistResponse = null;
|
||||
|
||||
export type NDGenre = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type NDGenreList = NDGenre[];
|
||||
|
||||
export type NDGenreListParams = NDOrder &
|
||||
NDPagination & {
|
||||
_sort?: NDGenreListSort;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type NDGenreListResponse = NDGenre[];
|
||||
|
||||
export type NDOrder = {
|
||||
_order?: NDSortOrder;
|
||||
};
|
||||
|
||||
export type NDPagination = {
|
||||
_end?: number;
|
||||
_start?: number;
|
||||
};
|
||||
|
||||
export type NDPlaylist = {
|
||||
comment: string;
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
evaluatedAt: string;
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
path: string;
|
||||
public: boolean;
|
||||
rules: null | Record<string, any>;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sync: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistDetail = NDPlaylist;
|
||||
|
||||
export type NDPlaylistDetailResponse = NDPlaylist;
|
||||
|
||||
export type NDPlaylistList = {
|
||||
items: NDPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDPlaylistListParams = NDOrder &
|
||||
NDPagination & {
|
||||
_sort?: NDPlaylistListSort;
|
||||
owner_id?: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistListResponse = NDPlaylist[];
|
||||
|
||||
export type NDPlaylistSong = NDSong & {
|
||||
mediaFileId: string;
|
||||
playlistId: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistSongList = {
|
||||
items: NDPlaylistSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDPlaylistSongListResponse = NDPlaylistSong[];
|
||||
|
||||
export type NDRemoveFromPlaylist = null;
|
||||
|
||||
export type NDRemoveFromPlaylistParams = {
|
||||
id: string[];
|
||||
};
|
||||
|
||||
export type NDRemoveFromPlaylistResponse = {
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDSong = {
|
||||
album: string;
|
||||
albumArtist: string;
|
||||
|
|
@ -107,275 +341,41 @@ export type NDSong = {
|
|||
year: number;
|
||||
};
|
||||
|
||||
export type NDAlbumArtist = {
|
||||
albumCount: number;
|
||||
biography: string;
|
||||
externalInfoUpdatedAt: string;
|
||||
externalUrl: string;
|
||||
fullText: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
largeImageUrl?: string;
|
||||
mbzArtistId: string;
|
||||
mediumImageUrl?: string;
|
||||
name: string;
|
||||
orderArtistName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
smallImageUrl?: string;
|
||||
songCount: number;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
} & {
|
||||
similarArtists?: SSArtistInfo['similarArtist'];
|
||||
};
|
||||
|
||||
export type NDAuthenticationResponse = NDAuthenticate;
|
||||
|
||||
export type NDAlbumArtistList = {
|
||||
items: NDAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDAlbumArtistDetail = NDAlbumArtist;
|
||||
|
||||
export type NDAlbumArtistDetailResponse = NDAlbumArtist;
|
||||
|
||||
export type NDGenreList = NDGenre[];
|
||||
|
||||
export type NDGenreListResponse = NDGenre[];
|
||||
|
||||
export type NDAlbumDetailResponse = NDAlbum;
|
||||
|
||||
export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
|
||||
|
||||
export type NDAlbumListResponse = NDAlbum[];
|
||||
|
||||
export type NDAlbumList = {
|
||||
items: NDAlbum[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDSongDetail = NDSong;
|
||||
|
||||
export type NDSongDetailResponse = NDSong;
|
||||
|
||||
export type NDSongListResponse = NDSong[];
|
||||
|
||||
export type NDSongList = {
|
||||
items: NDSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDArtistListResponse = NDAlbumArtist[];
|
||||
export type NDSongListParams = NDOrder &
|
||||
NDPagination & {
|
||||
_sort?: NDSongListSort;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
|
||||
export type NDPagination = {
|
||||
_end?: number;
|
||||
_start?: number;
|
||||
};
|
||||
|
||||
export enum NDSortOrder {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
|
||||
export type NDOrder = {
|
||||
_order?: NDSortOrder;
|
||||
};
|
||||
|
||||
export enum NDGenreListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type NDGenreListParams = {
|
||||
_sort?: NDGenreListSort;
|
||||
id?: string;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export enum NDAlbumListSort {
|
||||
ALBUM_ARTIST = 'album_artist',
|
||||
ARTIST = 'artist',
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'play_count',
|
||||
PLAY_DATE = 'play_date',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'recently_added',
|
||||
SONG_COUNT = 'songCount',
|
||||
STARRED = 'starred_at',
|
||||
YEAR = 'max_year',
|
||||
}
|
||||
|
||||
export type NDAlbumListParams = {
|
||||
_sort?: NDAlbumListSort;
|
||||
album_id?: string;
|
||||
artist_id?: string;
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export enum NDSongListSort {
|
||||
ALBUM = 'album',
|
||||
ALBUM_ARTIST = 'order_album_artist_name',
|
||||
ALBUM_SONGS = 'album',
|
||||
ARTIST = 'artist',
|
||||
BPM = 'bpm',
|
||||
CHANNELS = 'channels',
|
||||
COMMENT = 'comment',
|
||||
DURATION = 'duration',
|
||||
FAVORITED = 'starred_at',
|
||||
GENRE = 'genre',
|
||||
ID = 'id',
|
||||
PLAY_COUNT = 'playCount',
|
||||
PLAY_DATE = 'playDate',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
RECENTLY_ADDED = 'createdAt',
|
||||
TITLE = 'title',
|
||||
TRACK = 'track',
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export type NDSongListParams = {
|
||||
_sort?: NDSongListSort;
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export enum NDAlbumArtistListSort {
|
||||
ALBUM_COUNT = 'albumCount',
|
||||
FAVORITED = 'starred_at',
|
||||
NAME = 'name',
|
||||
PLAY_COUNT = 'playCount',
|
||||
RATING = 'rating',
|
||||
SONG_COUNT = 'songCount',
|
||||
}
|
||||
|
||||
export type NDAlbumArtistListParams = {
|
||||
_sort?: NDAlbumArtistListSort;
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDAddToPlaylistResponse = {
|
||||
added: number;
|
||||
};
|
||||
|
||||
export type NDAddToPlaylistBody = {
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDAddToPlaylist = null;
|
||||
|
||||
export type NDRemoveFromPlaylistResponse = {
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type NDRemoveFromPlaylistParams = {
|
||||
id: string[];
|
||||
};
|
||||
|
||||
export type NDRemoveFromPlaylist = null;
|
||||
|
||||
export type NDCreatePlaylistParams = {
|
||||
comment?: string;
|
||||
name: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
export type NDCreatePlaylistResponse = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type NDCreatePlaylist = NDCreatePlaylistResponse;
|
||||
export type NDSongListResponse = NDSong[];
|
||||
|
||||
export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
|
||||
|
||||
export type NDUpdatePlaylistResponse = NDPlaylist;
|
||||
|
||||
export type NDDeletePlaylistParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type NDDeletePlaylistResponse = null;
|
||||
|
||||
export type NDDeletePlaylist = NDDeletePlaylistResponse;
|
||||
|
||||
export type NDPlaylist = {
|
||||
comment: string;
|
||||
export type NDUser = {
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
evaluatedAt: string;
|
||||
email: string;
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
lastAccessAt: string;
|
||||
lastLoginAt: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
path: string;
|
||||
public: boolean;
|
||||
rules: Record<string, any> | null;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sync: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistDetail = NDPlaylist;
|
||||
|
||||
export type NDPlaylistDetailResponse = NDPlaylist;
|
||||
|
||||
export type NDPlaylistList = {
|
||||
items: NDPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type NDPlaylistListResponse = NDPlaylist[];
|
||||
|
||||
export enum NDPlaylistListSort {
|
||||
DURATION = 'duration',
|
||||
NAME = 'name',
|
||||
OWNER = 'owner_name',
|
||||
PUBLIC = 'public',
|
||||
SONG_COUNT = 'songCount',
|
||||
UPDATED_AT = 'updatedAt',
|
||||
}
|
||||
|
||||
export type NDPlaylistListParams = {
|
||||
_sort?: NDPlaylistListSort;
|
||||
owner_id?: string;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDPlaylistSong = NDSong & {
|
||||
mediaFileId: string;
|
||||
playlistId: string;
|
||||
};
|
||||
|
||||
export type NDPlaylistSongListResponse = NDPlaylistSong[];
|
||||
|
||||
export type NDPlaylistSongList = {
|
||||
items: NDPlaylistSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
export const NDSongQueryFields = [
|
||||
|
|
@ -515,12 +515,9 @@ export const NDSongQueryNumberOperators = [
|
|||
{ label: 'is in the range', value: 'inTheRange' },
|
||||
];
|
||||
|
||||
export type NDUserListParams = {
|
||||
_sort?: NDUserListSort;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDUserListResponse = NDUser[];
|
||||
export enum NDUserListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type NDUserList = {
|
||||
items: NDUser[];
|
||||
|
|
@ -528,6 +525,9 @@ export type NDUserList = {
|
|||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export enum NDUserListSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
export type NDUserListParams = NDOrder &
|
||||
NDPagination & {
|
||||
_sort?: NDUserListSort;
|
||||
};
|
||||
|
||||
export type NDUserListResponse = NDUser[];
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
||||
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
||||
import isElectron from 'is-electron';
|
||||
import debounce from 'lodash/debounce';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
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/api/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
import { ndType } from './navidrome-types';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
|
||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
|
@ -275,7 +277,7 @@ axiosClient.interceptors.response.use(
|
|||
// eslint-disable-next-line promise/no-promise-in-callback
|
||||
return localSettings
|
||||
.passwordGet(currentServer.id)
|
||||
.then(async (password: string | null) => {
|
||||
.then(async (password: null | string) => {
|
||||
authSuccess = false;
|
||||
|
||||
if (password === null) {
|
||||
|
|
@ -367,14 +369,14 @@ axiosClient.interceptors.response.use(
|
|||
);
|
||||
|
||||
export const ndApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
server: null | ServerListItem;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
const { server, signal, url } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
api: async ({ body, headers, method, path }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
|
||||
|
|
@ -406,7 +408,7 @@ export const ndApiClient = (args: {
|
|||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
} catch (e: any | AxiosError | Error) {
|
||||
if (isAxiosError(e)) {
|
||||
if (e.code === 'ERR_NETWORK') {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -1,28 +1,29 @@
|
|||
import {
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
AuthenticationResponse,
|
||||
ControllerEndpoint,
|
||||
genreListSortMap,
|
||||
playlistListSortMap,
|
||||
PlaylistSongListArgs,
|
||||
PlaylistSongListResponse,
|
||||
ServerListItem,
|
||||
Song,
|
||||
songListSortMap,
|
||||
sortOrderMap,
|
||||
userListSortMap,
|
||||
} from '../types';
|
||||
|
||||
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
||||
import { NDSongListSort } from '/@/renderer/api/navidrome.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 {
|
||||
albumArtistListSortMap,
|
||||
sortOrderMap,
|
||||
AuthenticationResponse,
|
||||
userListSortMap,
|
||||
albumListSortMap,
|
||||
songListSortMap,
|
||||
playlistListSortMap,
|
||||
PlaylistSongListArgs,
|
||||
PlaylistSongListResponse,
|
||||
genreListSortMap,
|
||||
Song,
|
||||
ControllerEndpoint,
|
||||
ServerListItem,
|
||||
} from '../types';
|
||||
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
|
||||
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
|
||||
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { NDSongListSort } from '/@/renderer/api/navidrome.types';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { getFeatures, hasFeature, VersionInfo } from '/@/renderer/api/utils';
|
||||
|
||||
const VERSION_INFO: VersionInfo = [
|
||||
['0.55.0', { [ServerFeature.BFR]: [1] }],
|
||||
|
|
@ -48,7 +49,7 @@ const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
|
|||
|
||||
const EXCLUDED_TAGS = new Set<string>(['disctotal', 'genre', 'tracktotal']);
|
||||
|
||||
const excludeMissing = (server: ServerListItem | null) => {
|
||||
const excludeMissing = (server: null | ServerListItem) => {
|
||||
if (hasFeature(server, ServerFeature.BFR)) {
|
||||
return { missing: false };
|
||||
}
|
||||
|
|
@ -58,7 +59,7 @@ const excludeMissing = (server: ServerListItem | null) => {
|
|||
|
||||
export const NavidromeController: ControllerEndpoint = {
|
||||
addToPlaylist: async (args) => {
|
||||
const { body, query, apiClientProps } = args;
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).addToPlaylist({
|
||||
body: {
|
||||
|
|
@ -98,7 +99,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
},
|
||||
createFavorite: SubsonicController.createFavorite,
|
||||
createPlaylist: async (args) => {
|
||||
const { body, apiClientProps } = args;
|
||||
const { apiClientProps, body } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).createPlaylist({
|
||||
body: {
|
||||
|
|
@ -120,7 +121,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
},
|
||||
deleteFavorite: SubsonicController.deleteFavorite,
|
||||
deletePlaylist: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).deletePlaylist({
|
||||
params: {
|
||||
|
|
@ -135,7 +136,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
getAlbumArtistDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
|
||||
params: {
|
||||
|
|
@ -176,7 +177,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
);
|
||||
},
|
||||
getAlbumArtistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
|
|
@ -217,7 +218,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getAlbumDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
|
||||
params: {
|
||||
|
|
@ -245,7 +246,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
);
|
||||
},
|
||||
getAlbumInfo: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const albumInfo = await ssApiClient(apiClientProps).getAlbumInfo2({
|
||||
query: {
|
||||
|
|
@ -265,7 +266,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getAlbumList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumList({
|
||||
query: {
|
||||
|
|
@ -299,7 +300,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getArtistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
|
|
@ -341,7 +342,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
}).then((result) => result!.totalRecordCount!),
|
||||
getDownloadUrl: SubsonicController.getDownloadUrl,
|
||||
getGenreList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getGenreList({
|
||||
query: {
|
||||
|
|
@ -366,7 +367,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
getLyrics: SubsonicController.getLyrics,
|
||||
getMusicFolderList: SubsonicController.getMusicFolderList,
|
||||
getPlaylistDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
|
||||
params: {
|
||||
|
|
@ -381,7 +382,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
return ndNormalize.playlist(res.body.data, apiClientProps.server);
|
||||
},
|
||||
getPlaylistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
const customQuery = query._custom?.navidrome;
|
||||
|
||||
// Smart playlists only became available in 0.48.0. Do not filter for previous versions
|
||||
|
|
@ -420,7 +421,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
|
||||
params: {
|
||||
|
|
@ -548,7 +549,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
}, []);
|
||||
},
|
||||
getSongDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getSongDetail({
|
||||
params: {
|
||||
|
|
@ -563,7 +564,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
return ndNormalize.song(res.body.data, apiClientProps.server);
|
||||
},
|
||||
getSongList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
|
|
@ -642,7 +643,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
getTopSongs: SubsonicController.getTopSongs,
|
||||
getTranscodingUrl: SubsonicController.getTranscodingUrl,
|
||||
getUserList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getUserList({
|
||||
query: {
|
||||
|
|
@ -682,7 +683,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
}
|
||||
},
|
||||
removeFromPlaylist: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
|
||||
params: {
|
||||
|
|
@ -703,7 +704,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
search: SubsonicController.search,
|
||||
setRating: SubsonicController.setRating,
|
||||
shareItem: async (args) => {
|
||||
const { body, apiClientProps } = args;
|
||||
const { apiClientProps, body } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).shareItem({
|
||||
body: {
|
||||
|
|
@ -724,7 +725,7 @@ export const NavidromeController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
updatePlaylist: async (args) => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).updatePlaylist({
|
||||
body: {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import z from 'zod';
|
||||
|
||||
import { ndType } from './navidrome-types';
|
||||
|
||||
import { NDGenre } from '/@/renderer/api/navidrome.types';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import {
|
||||
Song,
|
||||
LibraryItem,
|
||||
Album,
|
||||
Playlist,
|
||||
User,
|
||||
AlbumArtist,
|
||||
Genre,
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
RelatedArtist,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
RelatedArtist,
|
||||
Song,
|
||||
User,
|
||||
} from '/@/renderer/api/types';
|
||||
import z from 'zod';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { NDGenre } from '/@/renderer/api/navidrome.types';
|
||||
|
||||
const getImageUrl = (args: { url: string | null }) => {
|
||||
const getImageUrl = (args: { url: null | string }) => {
|
||||
const { url } = args;
|
||||
if (url === '/app/artist-placeholder.webp') {
|
||||
return null;
|
||||
|
|
@ -51,19 +53,19 @@ interface WithDate {
|
|||
playDate?: string;
|
||||
}
|
||||
|
||||
const normalizePlayDate = (item: WithDate): string | null => {
|
||||
const normalizePlayDate = (item: WithDate): null | string => {
|
||||
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
|
||||
};
|
||||
|
||||
const getArtists = (
|
||||
item:
|
||||
| z.infer<typeof ndType._response.song>
|
||||
| z.infer<typeof ndType._response.album>
|
||||
| z.infer<typeof ndType._response.playlistSong>
|
||||
| z.infer<typeof ndType._response.album>,
|
||||
| z.infer<typeof ndType._response.song>,
|
||||
) => {
|
||||
let albumArtists: RelatedArtist[] | undefined;
|
||||
let artists: RelatedArtist[] | undefined;
|
||||
let participants: Record<string, RelatedArtist[]> | null = null;
|
||||
let participants: null | Record<string, RelatedArtist[]> = null;
|
||||
|
||||
if (item.participants) {
|
||||
participants = {};
|
||||
|
|
@ -120,8 +122,8 @@ const getArtists = (
|
|||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
|
||||
server: ServerListItem | null,
|
||||
item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
let id;
|
||||
|
|
@ -204,7 +206,7 @@ const normalizeAlbum = (
|
|||
item: z.infer<typeof ndType._response.album> & {
|
||||
songs?: z.infer<typeof ndType._response.songList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
|
|
@ -268,7 +270,7 @@ const normalizeAlbumArtist = (
|
|||
item: z.infer<typeof ndType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
): AlbumArtist => {
|
||||
let imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
|
||||
|
||||
|
|
@ -332,7 +334,7 @@ const normalizeAlbumArtist = (
|
|||
|
||||
const normalizePlaylist = (
|
||||
item: z.infer<typeof ndType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
NDAlbumArtistListSort,
|
||||
NDAlbumListSort,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,30 @@
|
|||
import { QueryFunctionContext } from '@tanstack/react-query';
|
||||
import { LyricSource } from './types';
|
||||
|
||||
import type {
|
||||
AlbumListQuery,
|
||||
SongListQuery,
|
||||
AlbumDetailQuery,
|
||||
AlbumArtistListQuery,
|
||||
ArtistListQuery,
|
||||
PlaylistListQuery,
|
||||
PlaylistDetailQuery,
|
||||
PlaylistSongListQuery,
|
||||
UserListQuery,
|
||||
AlbumArtistDetailQuery,
|
||||
TopSongListQuery,
|
||||
SearchQuery,
|
||||
SongDetailQuery,
|
||||
RandomSongListQuery,
|
||||
LyricsQuery,
|
||||
LyricSearchQuery,
|
||||
AlbumArtistListQuery,
|
||||
AlbumDetailQuery,
|
||||
AlbumListQuery,
|
||||
ArtistListQuery,
|
||||
GenreListQuery,
|
||||
LyricSearchQuery,
|
||||
LyricsQuery,
|
||||
PlaylistDetailQuery,
|
||||
PlaylistListQuery,
|
||||
PlaylistSongListQuery,
|
||||
RandomSongListQuery,
|
||||
SearchQuery,
|
||||
SimilarSongsQuery,
|
||||
SongDetailQuery,
|
||||
SongListQuery,
|
||||
TopSongListQuery,
|
||||
UserListQuery,
|
||||
} from './types';
|
||||
|
||||
import { LyricSource } from './types';
|
||||
|
||||
export const splitPaginatedQuery = (key: any) => {
|
||||
const { startIndex, limit, ...filter } = key || {};
|
||||
const { limit, startIndex, ...filter } = key || {};
|
||||
|
||||
if (startIndex !== undefined || limit !== undefined) {
|
||||
return {
|
||||
|
|
@ -51,7 +53,7 @@ export const queryKeys: Record<
|
|||
> = {
|
||||
albumArtists: {
|
||||
count: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albumArtists', 'count', filter, pagination] as const;
|
||||
|
|
@ -68,7 +70,7 @@ export const queryKeys: Record<
|
|||
return [serverId, 'albumArtists', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'albumArtists', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
|
@ -87,7 +89,7 @@ export const queryKeys: Record<
|
|||
},
|
||||
albums: {
|
||||
count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination && artistId) {
|
||||
return [serverId, 'albums', 'count', artistId, filter, pagination] as const;
|
||||
|
|
@ -110,7 +112,7 @@ export const queryKeys: Record<
|
|||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||
[serverId, 'albums', 'detail', query] as const,
|
||||
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && pagination && artistId) {
|
||||
return [serverId, 'albums', 'list', artistId, filter, pagination] as const;
|
||||
|
|
@ -144,7 +146,7 @@ export const queryKeys: Record<
|
|||
},
|
||||
artists: {
|
||||
list: (serverId: string, query?: ArtistListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'artists', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
|
@ -159,7 +161,7 @@ export const queryKeys: Record<
|
|||
},
|
||||
genres: {
|
||||
list: (serverId: string, query?: GenreListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'genres', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
|
@ -177,7 +179,7 @@ export const queryKeys: Record<
|
|||
},
|
||||
playlists: {
|
||||
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'playlists', id, 'detail', filter, pagination] as const;
|
||||
}
|
||||
|
|
@ -190,7 +192,7 @@ export const queryKeys: Record<
|
|||
return [serverId, 'playlists', 'detail'] as const;
|
||||
},
|
||||
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
|
||||
if (query && id && pagination) {
|
||||
return [serverId, 'playlists', id, 'detailSongList', filter, pagination] as const;
|
||||
|
|
@ -205,7 +207,7 @@ export const queryKeys: Record<
|
|||
return [serverId, 'playlists', 'detailSongList'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: PlaylistListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'playlists', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
|
@ -218,7 +220,7 @@ export const queryKeys: Record<
|
|||
},
|
||||
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && id && pagination) {
|
||||
return [serverId, 'playlists', id, 'songList', filter, pagination] as const;
|
||||
}
|
||||
|
|
@ -246,7 +248,7 @@ export const queryKeys: Record<
|
|||
},
|
||||
songs: {
|
||||
count: (serverId: string, query?: SongListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'songs', 'count', filter, pagination] as const;
|
||||
}
|
||||
|
|
@ -262,7 +264,7 @@ export const queryKeys: Record<
|
|||
return [serverId, 'songs', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: SongListQuery) => {
|
||||
const { pagination, filter } = splitPaginatedQuery(query);
|
||||
const { filter, pagination } = splitPaginatedQuery(query);
|
||||
if (query && pagination) {
|
||||
return [serverId, 'songs', 'list', filter, pagination] as const;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,13 @@
|
|||
export type SSBaseResponse = {
|
||||
serverVersion?: 'string';
|
||||
status: 'string';
|
||||
type?: 'string';
|
||||
version: 'string';
|
||||
export type SSAlbum = SSAlbumListEntry & {
|
||||
song: SSSong[];
|
||||
};
|
||||
|
||||
export type SSMusicFolderList = SSMusicFolder[];
|
||||
|
||||
export type SSMusicFolderListResponse = {
|
||||
musicFolders: {
|
||||
musicFolder: SSMusicFolder[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSGenreList = SSGenre[];
|
||||
|
||||
export type SSGenreListResponse = {
|
||||
genres: {
|
||||
genre: SSGenre[];
|
||||
};
|
||||
};
|
||||
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
|
||||
|
||||
export type SSAlbumArtistDetailParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
|
||||
|
||||
export type SSAlbumArtistDetailResponse = {
|
||||
artist: SSAlbumArtistListEntry & {
|
||||
album: SSAlbumListEntry[];
|
||||
|
|
@ -36,7 +17,19 @@ export type SSAlbumArtistDetailResponse = {
|
|||
export type SSAlbumArtistList = {
|
||||
items: SSAlbumArtistListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
totalRecordCount: null | number;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListEntry = {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListParams = {
|
||||
musicFolderId?: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListResponse = {
|
||||
|
|
@ -47,72 +40,16 @@ export type SSAlbumArtistListResponse = {
|
|||
};
|
||||
};
|
||||
|
||||
export type SSAlbumList = {
|
||||
items: SSAlbumListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSAlbumListResponse = {
|
||||
albumList2: {
|
||||
album: SSAlbumListEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
|
||||
|
||||
export type SSAlbumDetailResponse = {
|
||||
album: SSAlbum;
|
||||
};
|
||||
|
||||
export type SSArtistInfoParams = {
|
||||
count?: number;
|
||||
id: string;
|
||||
includeNotPresent?: boolean;
|
||||
};
|
||||
|
||||
export type SSArtistInfoResponse = {
|
||||
artistInfo2: SSArtistInfo;
|
||||
};
|
||||
|
||||
export type SSArtistInfo = {
|
||||
biography: string;
|
||||
largeImageUrl?: string;
|
||||
lastFmUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
musicBrainzId?: string;
|
||||
similarArtist?: {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
smallImageUrl?: string;
|
||||
};
|
||||
|
||||
export type SSMusicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSGenre = {
|
||||
albumCount?: number;
|
||||
songCount?: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SSArtistIndex = {
|
||||
artist: SSAlbumArtistListEntry[];
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListEntry = {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
export type SSAlbumList = {
|
||||
items: SSAlbumListEntry[];
|
||||
startIndex: number;
|
||||
totalRecordCount: null | number;
|
||||
};
|
||||
|
||||
export type SSAlbumListEntry = {
|
||||
|
|
@ -135,9 +72,111 @@ export type SSAlbumListEntry = {
|
|||
year: number;
|
||||
};
|
||||
|
||||
export type SSAlbum = {
|
||||
song: SSSong[];
|
||||
} & SSAlbumListEntry;
|
||||
export type SSAlbumListParams = {
|
||||
fromYear?: number;
|
||||
genre?: string;
|
||||
musicFolderId?: string;
|
||||
offset?: number;
|
||||
size?: number;
|
||||
toYear?: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type SSAlbumListResponse = {
|
||||
albumList2: {
|
||||
album: SSAlbumListEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSArtistIndex = {
|
||||
artist: SSAlbumArtistListEntry[];
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSArtistInfo = {
|
||||
biography: string;
|
||||
largeImageUrl?: string;
|
||||
lastFmUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
musicBrainzId?: string;
|
||||
similarArtist?: {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
smallImageUrl?: string;
|
||||
};
|
||||
|
||||
export type SSArtistInfoParams = {
|
||||
count?: number;
|
||||
id: string;
|
||||
includeNotPresent?: boolean;
|
||||
};
|
||||
|
||||
export type SSArtistInfoResponse = {
|
||||
artistInfo2: SSArtistInfo;
|
||||
};
|
||||
|
||||
export type SSBaseResponse = {
|
||||
serverVersion?: 'string';
|
||||
status: 'string';
|
||||
type?: 'string';
|
||||
version: 'string';
|
||||
};
|
||||
|
||||
export type SSFavorite = null;
|
||||
|
||||
export type SSFavoriteParams = {
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type SSFavoriteResponse = null;
|
||||
|
||||
export type SSGenre = {
|
||||
albumCount?: number;
|
||||
songCount?: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SSGenreList = SSGenre[];
|
||||
|
||||
export type SSGenreListResponse = {
|
||||
genres: {
|
||||
genre: SSGenre[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSMusicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SSMusicFolderList = SSMusicFolder[];
|
||||
|
||||
export type SSMusicFolderListResponse = {
|
||||
musicFolders: {
|
||||
musicFolder: SSMusicFolder[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSRating = null;
|
||||
|
||||
export type SSRatingParams = {
|
||||
id: string;
|
||||
rating: number;
|
||||
};
|
||||
|
||||
export type SSRatingResponse = null;
|
||||
|
||||
export type SSScrobbleParams = {
|
||||
id: string;
|
||||
submission?: boolean;
|
||||
time?: number;
|
||||
};
|
||||
|
||||
export type SSSong = {
|
||||
album: string;
|
||||
|
|
@ -167,39 +206,12 @@ export type SSSong = {
|
|||
year: number;
|
||||
};
|
||||
|
||||
export type SSAlbumListParams = {
|
||||
fromYear?: number;
|
||||
genre?: string;
|
||||
musicFolderId?: string;
|
||||
offset?: number;
|
||||
size?: number;
|
||||
toYear?: number;
|
||||
type: string;
|
||||
export type SSTopSongList = {
|
||||
items: SSSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: null | number;
|
||||
};
|
||||
|
||||
export type SSAlbumArtistListParams = {
|
||||
musicFolderId?: string;
|
||||
};
|
||||
|
||||
export type SSFavoriteParams = {
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type SSFavorite = null;
|
||||
|
||||
export type SSFavoriteResponse = null;
|
||||
|
||||
export type SSRatingParams = {
|
||||
id: string;
|
||||
rating: number;
|
||||
};
|
||||
|
||||
export type SSRating = null;
|
||||
|
||||
export type SSRatingResponse = null;
|
||||
|
||||
export type SSTopSongListParams = {
|
||||
artist: string;
|
||||
count?: number;
|
||||
|
|
@ -210,15 +222,3 @@ export type SSTopSongListResponse = {
|
|||
song: SSSong[];
|
||||
};
|
||||
};
|
||||
|
||||
export type SSTopSongList = {
|
||||
items: SSSong[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSScrobbleParams = {
|
||||
id: string;
|
||||
submission?: boolean;
|
||||
time?: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
||||
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import qs from 'qs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
|
@ -284,15 +285,15 @@ const silentlyTransformResponse = (data: any) => {
|
|||
};
|
||||
|
||||
export const ssApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
server: null | ServerListItem;
|
||||
signal?: AbortSignal;
|
||||
silent?: boolean;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal, silent } = args;
|
||||
const { server, signal, silent, url } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
api: async ({ body, headers, method, path }) => {
|
||||
let baseUrl: string | undefined;
|
||||
const authParams: Record<string, any> = {};
|
||||
|
||||
|
|
@ -339,7 +340,7 @@ export const ssApiClient = (args: {
|
|||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
} catch (e: any | AxiosError | Error) {
|
||||
if (isAxiosError(e)) {
|
||||
if (e.code === 'ERR_NETWORK') {
|
||||
throw new Error(
|
||||
|
|
|
|||
|
|
@ -2,44 +2,45 @@ import dayjs from 'dayjs';
|
|||
import filter from 'lodash/filter';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import md5 from 'md5';
|
||||
|
||||
import { ServerFeatures } from '/@/renderer/api/features-types';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||
import { AlbumListSortType, SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import {
|
||||
LibraryItem,
|
||||
Song,
|
||||
ControllerEndpoint,
|
||||
sortSongList,
|
||||
sortAlbumArtistList,
|
||||
PlaylistListSort,
|
||||
GenreListSort,
|
||||
AlbumListSort,
|
||||
ControllerEndpoint,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
PlaylistListSort,
|
||||
Song,
|
||||
sortAlbumArtistList,
|
||||
sortAlbumList,
|
||||
SortOrder,
|
||||
sortSongList,
|
||||
} from '/@/renderer/api/types';
|
||||
import { randomString } from '/@/renderer/utils';
|
||||
import { ServerFeatures } from '/@/renderer/api/features-types';
|
||||
|
||||
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
|
||||
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
|
||||
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
|
||||
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
|
||||
[AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST,
|
||||
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
|
||||
[AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,
|
||||
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
|
||||
[AlbumListSort.COMMUNITY_RATING]: undefined,
|
||||
[AlbumListSort.DURATION]: undefined,
|
||||
[AlbumListSort.CRITIC_RATING]: undefined,
|
||||
[AlbumListSort.RATING]: undefined,
|
||||
[AlbumListSort.ARTIST]: undefined,
|
||||
[AlbumListSort.COMMUNITY_RATING]: undefined,
|
||||
[AlbumListSort.CRITIC_RATING]: undefined,
|
||||
[AlbumListSort.DURATION]: undefined,
|
||||
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
|
||||
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
|
||||
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
|
||||
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
|
||||
[AlbumListSort.RATING]: undefined,
|
||||
[AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST,
|
||||
[AlbumListSort.RECENTLY_PLAYED]: AlbumListSortType.RECENT,
|
||||
[AlbumListSort.RELEASE_DATE]: undefined,
|
||||
[AlbumListSort.SONG_COUNT]: undefined,
|
||||
[AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,
|
||||
};
|
||||
|
||||
export const SubsonicController: ControllerEndpoint = {
|
||||
addToPlaylist: async ({ body, query, apiClientProps }) => {
|
||||
addToPlaylist: async ({ apiClientProps, body, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
playlistId: query.id,
|
||||
|
|
@ -98,7 +99,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
createFavorite: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).createFavorite({
|
||||
query: {
|
||||
|
|
@ -114,7 +115,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
|
||||
return null;
|
||||
},
|
||||
createPlaylist: async ({ body, apiClientProps }) => {
|
||||
createPlaylist: async ({ apiClientProps, body }) => {
|
||||
const res = await ssApiClient(apiClientProps).createPlaylist({
|
||||
query: {
|
||||
name: body.name,
|
||||
|
|
@ -131,7 +132,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
deleteFavorite: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).removeFavorite({
|
||||
query: {
|
||||
|
|
@ -148,7 +149,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
deletePlaylist: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).deletePlaylist({
|
||||
query: {
|
||||
|
|
@ -163,7 +164,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
getAlbumArtistDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
|
|
@ -198,7 +199,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getAlbumArtistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getArtists({
|
||||
query: {
|
||||
|
|
@ -237,7 +238,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
getAlbumArtistListCount: (args) =>
|
||||
SubsonicController.getAlbumArtistList(args).then((res) => res!.totalRecordCount!),
|
||||
getAlbumDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getAlbum({
|
||||
query: {
|
||||
|
|
@ -252,7 +253,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
return ssNormalize.album(res.body.album, apiClientProps.server);
|
||||
},
|
||||
getAlbumList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (query.searchTerm) {
|
||||
const res = await ssApiClient(apiClientProps).search3({
|
||||
|
|
@ -398,7 +399,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getAlbumListCount: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (query.searchTerm) {
|
||||
let fetchNextPage = true;
|
||||
|
|
@ -516,7 +517,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
return totalRecordCount;
|
||||
},
|
||||
getArtistList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getArtists({
|
||||
query: {
|
||||
|
|
@ -570,7 +571,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
'&c=Feishin'
|
||||
);
|
||||
},
|
||||
getGenreList: async ({ query, apiClientProps }) => {
|
||||
getGenreList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getGenres({});
|
||||
|
|
@ -624,7 +625,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getPlaylistDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getPlaylist({
|
||||
query: {
|
||||
|
|
@ -638,7 +639,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
|
||||
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
|
||||
},
|
||||
getPlaylistList: async ({ query, apiClientProps }) => {
|
||||
getPlaylistList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getPlaylists({});
|
||||
|
|
@ -686,7 +687,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
totalRecordCount: results.length,
|
||||
};
|
||||
},
|
||||
getPlaylistListCount: async ({ query, apiClientProps }) => {
|
||||
getPlaylistListCount: async ({ apiClientProps, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).getPlaylists({});
|
||||
|
||||
if (res.status !== 200) {
|
||||
|
|
@ -705,7 +706,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
|
||||
return results.length;
|
||||
},
|
||||
getPlaylistSongList: async ({ query, apiClientProps }) => {
|
||||
getPlaylistSongList: async ({ apiClientProps, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).getPlaylist({
|
||||
query: {
|
||||
id: query.id,
|
||||
|
|
@ -731,7 +732,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getRandomSongList: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getRandomSongList({
|
||||
query: {
|
||||
|
|
@ -842,7 +843,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
}, []);
|
||||
},
|
||||
getSongDetail: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getSong({
|
||||
query: {
|
||||
|
|
@ -856,7 +857,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
|
||||
return ssNormalize.song(res.body.song, apiClientProps.server);
|
||||
},
|
||||
getSongList: async ({ query, apiClientProps }) => {
|
||||
getSongList: async ({ apiClientProps, query }) => {
|
||||
const fromAlbumPromises = [];
|
||||
const artistDetailPromises = [];
|
||||
let results: any[] = [];
|
||||
|
|
@ -1028,7 +1029,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getSongListCount: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
let fetchNextPage = true;
|
||||
let startIndex = 0;
|
||||
|
|
@ -1196,7 +1197,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
return totalRecordCount;
|
||||
},
|
||||
getStructuredLyrics: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getStructuredLyrics({
|
||||
query: {
|
||||
|
|
@ -1238,7 +1239,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
});
|
||||
},
|
||||
getTopSongs: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||
query: {
|
||||
|
|
@ -1261,7 +1262,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
getTranscodingUrl: (args) => {
|
||||
const { base, format, bitrate } = args.query;
|
||||
const { base, bitrate, format } = args.query;
|
||||
let url = base;
|
||||
if (format) {
|
||||
url += `&format=${format}`;
|
||||
|
|
@ -1272,7 +1273,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
|
||||
return url;
|
||||
},
|
||||
removeFromPlaylist: async ({ query, apiClientProps }) => {
|
||||
removeFromPlaylist: async ({ apiClientProps, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
playlistId: query.id,
|
||||
|
|
@ -1287,7 +1288,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
scrobble: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).scrobble({
|
||||
query: {
|
||||
|
|
@ -1304,7 +1305,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
},
|
||||
|
||||
search: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).search3({
|
||||
query: {
|
||||
|
|
@ -1335,7 +1336,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
};
|
||||
},
|
||||
setRating: async (args) => {
|
||||
const { query, apiClientProps } = args;
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const itemIds = query.item.map((item) => item.id);
|
||||
|
||||
|
|
@ -1351,7 +1352,7 @@ export const SubsonicController: ControllerEndpoint = {
|
|||
return null;
|
||||
},
|
||||
updatePlaylist: async (args) => {
|
||||
const { body, query, apiClientProps } = args;
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import {
|
||||
QueueSong,
|
||||
LibraryItem,
|
||||
AlbumArtist,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Genre,
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
QueueSong,
|
||||
RelatedArtist,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
Playlist,
|
||||
Genre,
|
||||
RelatedArtist,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
|
|
@ -37,9 +38,9 @@ const getCoverArtUrl = (args: {
|
|||
|
||||
const getArtists = (
|
||||
item:
|
||||
| z.infer<typeof ssType._response.song>
|
||||
| z.infer<typeof ssType._response.album>
|
||||
| z.infer<typeof ssType._response.albumListEntry>,
|
||||
| z.infer<typeof ssType._response.albumListEntry>
|
||||
| z.infer<typeof ssType._response.song>,
|
||||
) => {
|
||||
const albumArtists: RelatedArtist[] = item.albumArtists
|
||||
? item.albumArtists.map((item) => ({
|
||||
|
|
@ -69,7 +70,7 @@ const getArtists = (
|
|||
},
|
||||
];
|
||||
|
||||
let participants: Record<string, RelatedArtist[]> | null = null;
|
||||
let participants: null | Record<string, RelatedArtist[]> = null;
|
||||
|
||||
if (item.contributors) {
|
||||
participants = {};
|
||||
|
|
@ -98,9 +99,9 @@ const getArtists = (
|
|||
|
||||
const getGenres = (
|
||||
item:
|
||||
| z.infer<typeof ssType._response.song>
|
||||
| z.infer<typeof ssType._response.album>
|
||||
| z.infer<typeof ssType._response.albumListEntry>,
|
||||
| z.infer<typeof ssType._response.albumListEntry>
|
||||
| z.infer<typeof ssType._response.song>,
|
||||
): Genre[] => {
|
||||
return item.genres
|
||||
? item.genres.map((genre) => ({
|
||||
|
|
@ -123,7 +124,7 @@ const getGenres = (
|
|||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ssType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
size?: number,
|
||||
): QueueSong => {
|
||||
const imageUrl =
|
||||
|
|
@ -194,7 +195,7 @@ const normalizeAlbumArtist = (
|
|||
item:
|
||||
| z.infer<typeof ssType._response.albumArtist>
|
||||
| z.infer<typeof ssType._response.artistListEntry>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
|
|
@ -229,7 +230,7 @@ const normalizeAlbumArtist = (
|
|||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
const imageUrl =
|
||||
|
|
@ -280,7 +281,7 @@ const normalizePlaylist = (
|
|||
item:
|
||||
| z.infer<typeof ssType._response.playlist>
|
||||
| z.infer<typeof ssType._response.playlistListEntry>,
|
||||
server: ServerListItem | null,
|
||||
server: null | ServerListItem,
|
||||
): Playlist => {
|
||||
return {
|
||||
description: item.comment || null,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import isElectron from 'is-electron';
|
|||
import semverCoerce from 'semver/functions/coerce';
|
||||
import semverGte from 'semver/functions/gte';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ServerFeature } from '/@/renderer/api/features-types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
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) => {
|
||||
|
|
@ -29,7 +30,7 @@ export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
|
|||
});
|
||||
};
|
||||
|
||||
export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
||||
export const authenticationFailure = (currentServer: null | ServerListItem) => {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
|
@ -43,7 +44,7 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const hasFeature = (server: ServerListItem | null, feature: ServerFeature): boolean => {
|
||||
export const hasFeature = (server: null | ServerListItem, feature: ServerFeature): boolean => {
|
||||
if (!server || !server.features) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,39 @@
|
|||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
|
||||
import { ModuleRegistry } from '@ag-grid-community/core';
|
||||
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import isElectron from 'is-electron';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { initSimpleImg } from 'react-simple-img';
|
||||
|
||||
import i18n from '../i18n/i18n';
|
||||
import { toast } from './components';
|
||||
import { useTheme } from './hooks';
|
||||
import { IsUpdatedDialog } from './is-updated-dialog';
|
||||
import { AppRouter } from './router/app-router';
|
||||
import './styles/global.scss';
|
||||
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
import {
|
||||
useCssSettings,
|
||||
useHotkeySettings,
|
||||
usePlaybackSettings,
|
||||
useRemoteSettings,
|
||||
useSettingsStore,
|
||||
} from './store/settings.store';
|
||||
import './styles/global.scss';
|
||||
|
||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||
import { PlayerState, useCssSettings, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||
import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/renderer/types';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
|
||||
import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog';
|
||||
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||
import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/renderer/types';
|
||||
import { sanitizeCss } from '/@/renderer/utils/sanitize';
|
||||
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
|
||||
|
||||
|
|
@ -35,10 +41,10 @@ ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule
|
|||
|
||||
initSimpleImg({ threshold: 0.05 }, true);
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
const ipc = isElectron() ? window.electron.ipc : null;
|
||||
const remote = isElectron() ? window.electron.remote : null;
|
||||
const utils = isElectron() ? window.electron.utils : null;
|
||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
const remote = isElectron() ? window.api.remote : null;
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
export const App = () => {
|
||||
const theme = useTheme();
|
||||
|
|
@ -46,7 +52,7 @@ export const App = () => {
|
|||
const language = useSettingsStore((store) => store.general.language);
|
||||
const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio);
|
||||
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
|
||||
const { enabled, content } = useCssSettings();
|
||||
const { content, enabled } = useCssSettings();
|
||||
const { type: playbackType } = usePlaybackSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
|
|
@ -230,10 +236,8 @@ export const App = () => {
|
|||
|
||||
return (
|
||||
<MantineProvider
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
theme={{
|
||||
colorScheme: theme as 'light' | 'dark',
|
||||
colorScheme: theme as 'dark' | 'light',
|
||||
components: {
|
||||
Modal: {
|
||||
styles: {
|
||||
|
|
@ -282,6 +286,8 @@ export const App = () => {
|
|||
xs: '0rem',
|
||||
},
|
||||
}}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<PlayQueueHandlerContext.Provider value={providerValue}>
|
||||
<ContextMenuProvider>
|
||||
|
|
|
|||
31
src/renderer/assets/assets.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
type Styles = Record<string, string>;
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.sass' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.css' {
|
||||
const content: Styles;
|
||||
export default content;
|
||||
}
|
||||
10
src/renderer/assets/entitlements.mac.plist
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
src/renderer/assets/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
src/renderer/assets/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src/renderer/assets/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/renderer/assets/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/renderer/assets/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/renderer/assets/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src/renderer/assets/icons/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
src/renderer/assets/icons/icon.icns
Normal file
BIN
src/renderer/assets/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
src/renderer/assets/icons/icon.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/renderer/assets/pause-circle.png
Normal file
|
After Width: | Height: | Size: 896 B |
BIN
src/renderer/assets/play-circle.png
Normal file
|
After Width: | Height: | Size: 971 B |
BIN
src/renderer/assets/skip-next.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
src/renderer/assets/skip-previous.png
Normal file
|
After Width: | Height: | Size: 524 B |
|
|
@ -1,4 +1,5 @@
|
|||
import type { AccordionProps as MantineAccordionProps } from '@mantine/core';
|
||||
|
||||
import { Accordion as MantineAccordion } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,36 @@
|
|||
import type { Song } from '/@/renderer/api/types';
|
||||
import type { CrossfadeStyle } from '/@/renderer/types';
|
||||
import type { ReactPlayerProps } from 'react-player';
|
||||
|
||||
import isElectron from 'is-electron';
|
||||
import {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import type { ReactPlayerProps } from 'react-player';
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
import type { Song } from '/@/renderer/api/types';
|
||||
|
||||
import { api } from '/@/renderer/api';
|
||||
import {
|
||||
crossfadeHandler,
|
||||
gaplessHandler,
|
||||
} from '/@/renderer/components/audio-player/utils/list-handlers';
|
||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import type { CrossfadeStyle } from '/@/renderer/types';
|
||||
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
||||
import { getServerById, TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
|
||||
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
|
||||
|
||||
export type AudioPlayerProgress = {
|
||||
loaded: number;
|
||||
loadedSeconds: number;
|
||||
played: number;
|
||||
playedSeconds: number;
|
||||
};
|
||||
|
||||
interface AudioPlayerProps extends ReactPlayerProps {
|
||||
crossfadeDuration: number;
|
||||
|
|
@ -34,13 +43,6 @@ interface AudioPlayerProps extends ReactPlayerProps {
|
|||
volume: number;
|
||||
}
|
||||
|
||||
export type AudioPlayerProgress = {
|
||||
loaded: number;
|
||||
loadedSeconds: number;
|
||||
played: number;
|
||||
playedSeconds: number;
|
||||
};
|
||||
|
||||
const getDuration = (ref: any) => {
|
||||
return ref.current?.player?.player?.player?.duration;
|
||||
};
|
||||
|
|
@ -53,7 +55,7 @@ const getDuration = (ref: any) => {
|
|||
const EMPTY_SOURCE =
|
||||
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
|
||||
|
||||
const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song): string | null => {
|
||||
const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song): null | string => {
|
||||
const prior = useRef(['', '']);
|
||||
|
||||
return useMemo(() => {
|
||||
|
|
@ -94,15 +96,15 @@ const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song)
|
|||
export const AudioPlayer = forwardRef(
|
||||
(
|
||||
{
|
||||
status,
|
||||
playbackStyle,
|
||||
crossfadeStyle,
|
||||
crossfadeDuration,
|
||||
currentPlayer,
|
||||
autoNext,
|
||||
crossfadeDuration,
|
||||
crossfadeStyle,
|
||||
currentPlayer,
|
||||
muted,
|
||||
playbackStyle,
|
||||
player1,
|
||||
player2,
|
||||
muted,
|
||||
status,
|
||||
volume,
|
||||
}: AudioPlayerProps,
|
||||
ref: any,
|
||||
|
|
@ -120,7 +122,7 @@ export const AudioPlayer = forwardRef(
|
|||
const stream1 = useSongUrl(transcode, currentPlayer === 1, player1);
|
||||
const stream2 = useSongUrl(transcode, currentPlayer === 2, player2);
|
||||
|
||||
const { webAudio, setWebAudio } = useWebAudio();
|
||||
const { setWebAudio, webAudio } = useWebAudio();
|
||||
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -415,43 +417,43 @@ export const AudioPlayer = forwardRef(
|
|||
return (
|
||||
<>
|
||||
<ReactPlayer
|
||||
ref={player1Ref}
|
||||
config={{
|
||||
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
|
||||
}}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={stream1 || EMPTY_SOURCE}
|
||||
volume={webAudio ? 1 : volume}
|
||||
width={0}
|
||||
// If there is no stream url, we do not need to handle when the audio finishes
|
||||
onEnded={stream1 ? handleOnEnded : undefined}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
|
||||
}
|
||||
onReady={handlePlayer1Start}
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
ref={player1Ref}
|
||||
url={stream1 || EMPTY_SOURCE}
|
||||
volume={webAudio ? 1 : volume}
|
||||
width={0}
|
||||
/>
|
||||
<ReactPlayer
|
||||
ref={player2Ref}
|
||||
config={{
|
||||
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
|
||||
}}
|
||||
height={0}
|
||||
muted={muted}
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
url={stream2 || EMPTY_SOURCE}
|
||||
volume={webAudio ? 1 : volume}
|
||||
width={0}
|
||||
onEnded={stream2 ? handleOnEnded : undefined}
|
||||
onProgress={
|
||||
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
|
||||
}
|
||||
onReady={handlePlayer2Start}
|
||||
playbackRate={playbackSpeed}
|
||||
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
|
||||
progressInterval={isTransitioning ? 10 : 250}
|
||||
ref={player2Ref}
|
||||
url={stream2 || EMPTY_SOURCE}
|
||||
volume={webAudio ? 1 : volume}
|
||||
width={0}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable no-nested-ternary */
|
||||
import type { Dispatch } from 'react';
|
||||
|
||||
import { CrossfadeStyle } from '/@/renderer/types';
|
||||
|
||||
export const gaplessHandler = (args: {
|
||||
|
|
@ -10,7 +10,7 @@ export const gaplessHandler = (args: {
|
|||
nextPlayerRef: any;
|
||||
setIsTransitioning: Dispatch<boolean>;
|
||||
}) => {
|
||||
const { nextPlayerRef, currentTime, duration, isTransitioning, setIsTransitioning, isFlac } =
|
||||
const { currentTime, duration, isFlac, isTransitioning, nextPlayerRef, setIsTransitioning } =
|
||||
args;
|
||||
|
||||
if (!isTransitioning) {
|
||||
|
|
@ -46,17 +46,17 @@ export const crossfadeHandler = (args: {
|
|||
volume: number;
|
||||
}) => {
|
||||
const {
|
||||
currentTime,
|
||||
player,
|
||||
currentPlayer,
|
||||
currentPlayerRef,
|
||||
nextPlayerRef,
|
||||
currentTime,
|
||||
duration,
|
||||
fadeDuration,
|
||||
fadeType,
|
||||
duration,
|
||||
volume,
|
||||
isTransitioning,
|
||||
nextPlayerRef,
|
||||
player,
|
||||
setIsTransitioning,
|
||||
volume,
|
||||
} = args;
|
||||
|
||||
if (!isTransitioning || currentPlayer !== player) {
|
||||
|
|
@ -79,22 +79,18 @@ export const crossfadeHandler = (args: {
|
|||
let percentageOfFadeLeft;
|
||||
let n;
|
||||
switch (fadeType) {
|
||||
case 'equalPower':
|
||||
// https://dsp.stackexchange.com/a/14755
|
||||
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
|
||||
currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
|
||||
nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
|
||||
break;
|
||||
case 'linear':
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
case 'dipped':
|
||||
// https://math.stackexchange.com/a/4622
|
||||
percentageOfFadeLeft = timeLeft / fadeDuration;
|
||||
currentPlayerVolumeCalculation = percentageOfFadeLeft ** 2 * volume;
|
||||
nextPlayerVolumeCalculation = (percentageOfFadeLeft - 1) ** 2 * volume;
|
||||
break;
|
||||
case 'equalPower':
|
||||
// https://dsp.stackexchange.com/a/14755
|
||||
percentageOfFadeLeft = (timeLeft / fadeDuration) * 2;
|
||||
currentPlayerVolumeCalculation = Math.sqrt(0.5 * percentageOfFadeLeft) * volume;
|
||||
nextPlayerVolumeCalculation = Math.sqrt(0.5 * (2 - percentageOfFadeLeft)) * volume;
|
||||
break;
|
||||
case fadeType.match(/constantPower.*/)?.input:
|
||||
// https://math.stackexchange.com/a/26159
|
||||
n =
|
||||
|
|
@ -114,6 +110,10 @@ export const crossfadeHandler = (args: {
|
|||
Math.cos((Math.PI / 4) * ((2 * percentageOfFadeLeft - 1) ** (2 * n + 1) + 1)) *
|
||||
volume;
|
||||
break;
|
||||
case 'linear':
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
nextPlayerVolumeCalculation = ((fadeDuration - timeLeft) / fadeDuration) * volume;
|
||||
break;
|
||||
|
||||
default:
|
||||
currentPlayerVolumeCalculation = (timeLeft / fadeDuration) * volume;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { BadgeProps as MantineBadgeProps } from '@mantine/core';
|
||||
|
||||
import { createPolymorphicComponent, Badge as MantineBadge } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import type { Ref } from 'react';
|
||||
import React, { useRef, useCallback, useState, forwardRef } from 'react';
|
||||
import type { ButtonProps as MantineButtonProps, TooltipProps } from '@mantine/core';
|
||||
import { Button as MantineButton, createPolymorphicComponent } from '@mantine/core';
|
||||
import type { Ref } from 'react';
|
||||
|
||||
import { createPolymorphicComponent, Button as MantineButton } from '@mantine/core';
|
||||
import { useTimeout } from '@mantine/hooks';
|
||||
import React, { forwardRef, useCallback, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Spinner } from '/@/renderer/components/spinner';
|
||||
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||
|
||||
|
|
@ -94,8 +96,8 @@ export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
{...tooltip}
|
||||
>
|
||||
<StyledButton
|
||||
ref={ref}
|
||||
loaderPosition="center"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
|
|
@ -111,8 +113,8 @@ export const _Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
|
||||
return (
|
||||
<StyledButton
|
||||
ref={ref}
|
||||
loaderPosition="center"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<ButtonChildWrapper $loading={props.loading}>{children}</ButtonChildWrapper>
|
||||
|
|
@ -153,7 +155,7 @@ export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => {
|
|||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const { start, clear } = useTimeout(callback, timeoutProps.duration);
|
||||
const { clear, start } = useTimeout(callback, timeoutProps.duration);
|
||||
|
||||
const startTimeout = useCallback(() => {
|
||||
if (isRunning) {
|
||||
|
|
@ -174,8 +176,8 @@ export const TimeoutButton = ({ timeoutProps, ...props }: HoldButtonProps) => {
|
|||
|
||||
return (
|
||||
<Button
|
||||
sx={{ color: 'var(--danger-color)' }}
|
||||
onClick={startTimeout}
|
||||
sx={{ color: 'var(--danger-color)' }}
|
||||
{...props}
|
||||
>
|
||||
{isRunning ? 'Cancel' : props.children}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { CardRoute, CardRow, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
|
||||
import { Center } from '@mantine/core';
|
||||
import { useCallback } from 'react';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import styled from 'styled-components';
|
||||
import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { CardControls } from '/@/renderer/components/card/card-controls';
|
||||
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
import { CardControls } from '/@/renderer/components/card/card-controls';
|
||||
import { CardRows } from '/@/renderer/components/card/card-rows';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
link?: boolean;
|
||||
|
|
@ -104,7 +106,7 @@ const Row = styled.div<{ $secondary?: boolean }>`
|
|||
|
||||
interface BaseGridCardProps {
|
||||
controls: {
|
||||
cardRows: CardRow<Album | Artist | AlbumArtist>[];
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
|
|
@ -116,14 +118,14 @@ interface BaseGridCardProps {
|
|||
}
|
||||
|
||||
export const AlbumCard = ({
|
||||
controls,
|
||||
data,
|
||||
handlePlayQueueAdd,
|
||||
loading,
|
||||
size,
|
||||
handlePlayQueueAdd,
|
||||
data,
|
||||
controls,
|
||||
}: BaseGridCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { itemType, cardRows, route } = controls;
|
||||
const { cardRows, itemType, route } = controls;
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
navigate(
|
||||
|
|
@ -194,9 +196,9 @@ export const AlbumCard = ({
|
|||
<CardWrapper>
|
||||
<StyledCard style={{ alignItems: 'center', display: 'flex' }}>
|
||||
<Skeleton
|
||||
visible
|
||||
height={size}
|
||||
radius="sm"
|
||||
visible
|
||||
width={size}
|
||||
>
|
||||
<ImageSection />
|
||||
|
|
@ -204,10 +206,10 @@ export const AlbumCard = ({
|
|||
<DetailSection style={{ width: '100%' }}>
|
||||
{(cardRows || []).map((_row: CardRow<Album>, index: number) => (
|
||||
<Skeleton
|
||||
visible
|
||||
height={15}
|
||||
my={3}
|
||||
radius="md"
|
||||
visible
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
>
|
||||
<Row />
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
import type { MouseEvent } from 'react';
|
||||
import React from 'react';
|
||||
import type { UnstyledButtonProps } from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
import type { PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import type { UnstyledButtonProps } from '@mantine/core';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { Group } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { RiHeartFill, RiHeartLine, RiMore2Fill, RiPlayFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
import {
|
||||
ALBUM_CONTEXT_MENU_ITEMS,
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
type PlayButtonType = React.ComponentPropsWithoutRef<'button'> & UnstyledButtonProps;
|
||||
|
||||
const PlayButton = styled.button<PlayButtonType>`
|
||||
display: flex;
|
||||
|
|
@ -104,9 +106,9 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
|||
`;
|
||||
|
||||
export const CardControls = ({
|
||||
handlePlayQueueAdd,
|
||||
itemData,
|
||||
itemType,
|
||||
handlePlayQueueAdd,
|
||||
}: {
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
|
|
@ -156,14 +158,14 @@ export const CardControls = ({
|
|||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiMore2Fill
|
||||
color="white"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
import formatDuration from 'format-duration';
|
||||
import React from 'react';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Album, AlbumArtist, Artist, Playlist, Song } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components/text';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
|
@ -23,7 +24,7 @@ const Row = styled.div<{ $secondary?: boolean }>`
|
|||
|
||||
interface CardRowsProps {
|
||||
data: any;
|
||||
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
rows: CardRow<Album>[] | CardRow<AlbumArtist>[] | CardRow<Artist>[];
|
||||
}
|
||||
|
||||
export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
|
|
@ -33,8 +34,8 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||
if (row.arrayProperty && row.route) {
|
||||
return (
|
||||
<Row
|
||||
key={`row-${row.property}-${index}`}
|
||||
$secondary={index > 0}
|
||||
key={`row-${row.property}-${index}`}
|
||||
>
|
||||
{data[row.property].map((item: any, itemIndex: number) => (
|
||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
||||
|
|
@ -55,6 +56,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||
$noSelect
|
||||
$secondary={index > 0}
|
||||
component={Link}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
to={generatePath(
|
||||
|
|
@ -69,7 +71,6 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.arrayProperty &&
|
||||
(row.format
|
||||
|
|
@ -87,9 +88,9 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||
<Row key={`row-${row.property}`}>
|
||||
{data[row.property].map((item: any) => (
|
||||
<Text
|
||||
key={`${data.id}-${item.id}`}
|
||||
$noSelect
|
||||
$secondary={index > 0}
|
||||
key={`${data.id}-${item.id}`}
|
||||
overflow="hidden"
|
||||
size={index > 0 ? 'sm' : 'md'}
|
||||
>
|
||||
|
|
@ -108,6 +109,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||
$link
|
||||
$noSelect
|
||||
component={Link}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
overflow="hidden"
|
||||
to={generatePath(
|
||||
row.route.route,
|
||||
|
|
@ -118,7 +120,6 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||
};
|
||||
}, {}),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data && (row.format ? row.format(data) : data[row.property])}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@ import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri';
|
|||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
import { CardRows } from '/@/renderer/components/card';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
||||
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
|
||||
import { CardRoute, CardRow, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
|
||||
interface BaseGridCardProps {
|
||||
controls: {
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
cardRows: CardRow<Album>[] | CardRow<AlbumArtist>[] | CardRow<Artist>[];
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
|
|
@ -101,8 +102,8 @@ const DetailContainer = styled.div`
|
|||
`;
|
||||
|
||||
export const PosterCard = ({
|
||||
data,
|
||||
controls,
|
||||
data,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
}: BaseGridCardProps & { uniqueId: string }) => {
|
||||
|
|
@ -123,10 +124,10 @@ export const PosterCard = ({
|
|||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
|
|
@ -184,8 +185,8 @@ export const PosterCard = ({
|
|||
return (
|
||||
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
visible
|
||||
>
|
||||
<ImageContainerSkeleton />
|
||||
</Skeleton>
|
||||
|
|
@ -193,10 +194,10 @@ export const PosterCard = ({
|
|||
<Stack spacing="sm">
|
||||
{(controls?.cardRows || []).map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
key={`${index}-${row.arrayProperty}`}
|
||||
radius="sm"
|
||||
visible
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CheckboxProps, Checkbox as MantineCheckbox } from '@mantine/core';
|
||||
import { forwardRef } from 'react';
|
||||
import { Checkbox as MantineCheckbox, CheckboxProps } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledCheckbox = styled(MantineCheckbox)`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react';
|
||||
import { Box, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
|
||||
import { motion, Variants } from 'framer-motion';
|
||||
import { ComponentPropsWithoutRef, forwardRef, ReactNode, Ref } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface ContextMenuProps {
|
||||
|
|
@ -61,11 +61,11 @@ export const ContextMenuButton = forwardRef(
|
|||
(
|
||||
{
|
||||
children,
|
||||
rightIcon,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
...props
|
||||
}: UnstyledButtonProps &
|
||||
ComponentPropsWithoutRef<'button'> & {
|
||||
}: ComponentPropsWithoutRef<'button'> &
|
||||
UnstyledButtonProps & {
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
},
|
||||
|
|
@ -74,11 +74,11 @@ export const ContextMenuButton = forwardRef(
|
|||
return (
|
||||
<StyledContextMenuButton
|
||||
{...props}
|
||||
key={props.key}
|
||||
ref={ref}
|
||||
as="button"
|
||||
disabled={props.disabled}
|
||||
key={props.key}
|
||||
onClick={props.onClick}
|
||||
ref={ref}
|
||||
>
|
||||
<Group position="apart">
|
||||
<Group spacing="md">
|
||||
|
|
@ -108,14 +108,14 @@ const variants: Variants = {
|
|||
};
|
||||
|
||||
export const ContextMenu = forwardRef(
|
||||
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||
({ children, maxWidth, minWidth, xPos, yPos }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||
return (
|
||||
<ContextMenuContainer
|
||||
ref={ref}
|
||||
animate="open"
|
||||
initial="closed"
|
||||
maxWidth={maxWidth}
|
||||
minWidth={minWidth}
|
||||
ref={ref}
|
||||
variants={variants}
|
||||
xPos={xPos}
|
||||
yPos={yPos}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { DatePickerProps as MantineDatePickerProps } from '@mantine/dates';
|
||||
|
||||
import { DatePicker as MantineDatePicker } from '@mantine/dates';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
|
|||
}
|
||||
`;
|
||||
|
||||
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
|
||||
export const DatePicker = ({ maxWidth, width, ...props }: DatePickerProps) => {
|
||||
return (
|
||||
<StyledDatePicker
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { DialogProps as MantineDialogProps } from '@mantine/core';
|
||||
|
||||
import { Dialog as MantineDialog } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,25 @@
|
|||
import { ReactNode } from 'react';
|
||||
import type {
|
||||
MenuProps as MantineMenuProps,
|
||||
MenuItemProps as MantineMenuItemProps,
|
||||
MenuLabelProps as MantineMenuLabelProps,
|
||||
MenuDividerProps as MantineMenuDividerProps,
|
||||
MenuDropdownProps as MantineMenuDropdownProps,
|
||||
MenuItemProps as MantineMenuItemProps,
|
||||
MenuLabelProps as MantineMenuLabelProps,
|
||||
MenuProps as MantineMenuProps,
|
||||
} from '@mantine/core';
|
||||
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
|
||||
|
||||
import { createPolymorphicComponent, Menu as MantineMenu } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
import { RiArrowLeftSFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type MenuProps = MantineMenuProps;
|
||||
type MenuLabelProps = MantineMenuLabelProps;
|
||||
type MenuDividerProps = MantineMenuDividerProps;
|
||||
type MenuDropdownProps = MantineMenuDropdownProps;
|
||||
interface MenuItemProps extends MantineMenuItemProps {
|
||||
$danger?: boolean;
|
||||
$isActive?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
type MenuDividerProps = MantineMenuDividerProps;
|
||||
type MenuDropdownProps = MantineMenuDropdownProps;
|
||||
type MenuLabelProps = MantineMenuLabelProps;
|
||||
type MenuProps = MantineMenuProps;
|
||||
|
||||
const StyledMenu = styled(MantineMenu)<MenuProps>``;
|
||||
|
||||
|
|
@ -81,7 +82,6 @@ const StyledMenuDivider = styled(MantineMenu.Divider)`
|
|||
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
||||
return (
|
||||
<StyledMenu
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
|
|
@ -90,6 +90,7 @@ export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
|||
transitionProps={{
|
||||
transition: 'fade',
|
||||
}}
|
||||
withinPortal
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -101,7 +102,7 @@ const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
|
|||
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
|
||||
};
|
||||
|
||||
const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) => {
|
||||
const pMenuItem = ({ $danger, $isActive, children, ...props }: MenuItemProps) => {
|
||||
return (
|
||||
<StyledMenuItem
|
||||
$danger={$danger}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
import type { MouseEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Group, Image, Stack } from '@mantine/core';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { Group, Image, Stack } from '@mantine/core';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
import { Link, generatePath } from 'react-router-dom';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Album, LibraryItem } from '/@/renderer/api/types';
|
||||
import { Badge } from '/@/renderer/components/badge';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { TextTitle } from '/@/renderer/components/text-title';
|
||||
import { Badge } from '/@/renderer/components/badge';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const Carousel = styled(motion.div)`
|
||||
position: relative;
|
||||
|
|
@ -152,11 +154,11 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||
>
|
||||
{data && (
|
||||
<Carousel
|
||||
key={`image-${itemIndex}`}
|
||||
animate="animate"
|
||||
custom={direction}
|
||||
exit="exit"
|
||||
initial="initial"
|
||||
key={`image-${itemIndex}`}
|
||||
variants={variants}
|
||||
>
|
||||
<Grid>
|
||||
|
|
@ -218,9 +220,6 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||
</Group>
|
||||
<Group position="apart">
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ borderRadius: '5rem' }}
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -234,6 +233,9 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||
playType,
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
style={{ borderRadius: '5rem' }}
|
||||
variant="outline"
|
||||
>
|
||||
{t(
|
||||
playType === Play.NOW
|
||||
|
|
@ -246,18 +248,18 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||
</Button>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
onClick={handlePrevious}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<RiArrowLeftSLine size="2rem" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine size="2rem" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import throttle from 'lodash/throttle';
|
||||
import {
|
||||
isValidElement,
|
||||
memo,
|
||||
|
|
@ -9,14 +11,13 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { SwiperOptions, Virtual } from 'swiper';
|
||||
import 'swiper/css';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Swiper as SwiperCore } from 'swiper/types';
|
||||
|
||||
import { Album, AlbumArtist, Artist, LibraryItem, RelatedArtist } from '/@/renderer/api/types';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { PosterCard } from '/@/renderer/components/card/poster-card';
|
||||
|
|
@ -44,14 +45,14 @@ const CarouselContainer = styled(Stack)`
|
|||
interface TitleProps {
|
||||
handleNext?: () => void;
|
||||
handlePrev?: () => void;
|
||||
label?: string | ReactNode;
|
||||
label?: ReactNode | string;
|
||||
pagination: {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const Title = ({ label, handleNext, handlePrev, pagination }: TitleProps) => {
|
||||
const Title = ({ handleNext, handlePrev, label, pagination }: TitleProps) => {
|
||||
return (
|
||||
<Group position="apart">
|
||||
{isValidElement(label) ? (
|
||||
|
|
@ -69,18 +70,18 @@ const Title = ({ label, handleNext, handlePrev, pagination }: TitleProps) => {
|
|||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasPreviousPage}
|
||||
onClick={handlePrev}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasNextPage}
|
||||
onClick={handleNext}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
|
|
@ -90,7 +91,7 @@ const Title = ({ label, handleNext, handlePrev, pagination }: TitleProps) => {
|
|||
};
|
||||
|
||||
export interface SwiperGridCarouselProps {
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
cardRows: CardRow<Album>[] | CardRow<AlbumArtist>[] | CardRow<Artist>[];
|
||||
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
|
||||
isLoading?: boolean;
|
||||
itemType: LibraryItem;
|
||||
|
|
@ -100,7 +101,7 @@ export interface SwiperGridCarouselProps {
|
|||
children?: ReactNode;
|
||||
hasPagination?: boolean;
|
||||
icon?: ReactNode;
|
||||
label: string | ReactNode;
|
||||
label: ReactNode | string;
|
||||
};
|
||||
uniqueId: string;
|
||||
}
|
||||
|
|
@ -108,15 +109,15 @@ export interface SwiperGridCarouselProps {
|
|||
export const SwiperGridCarousel = ({
|
||||
cardRows,
|
||||
data,
|
||||
isLoading,
|
||||
itemType,
|
||||
route,
|
||||
swiperProps,
|
||||
title,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
}: SwiperGridCarouselProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const swiperRef = useRef<SwiperCore | any>(null);
|
||||
const swiperRef = useRef<any | SwiperCore>(null);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const [slideCount, setSlideCount] = useState(4);
|
||||
|
|
@ -140,7 +141,7 @@ export const SwiperGridCarousel = ({
|
|||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite, serverId } = options;
|
||||
const { id, isFavorite, itemType, serverId } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
|
|
@ -205,7 +206,7 @@ export const SwiperGridCarousel = ({
|
|||
}, [slideCount, swiperProps?.slidesPerView]);
|
||||
|
||||
const handleOnSlideChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
const { isBeginning, isEnd, params, slides } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
|
|
@ -216,7 +217,7 @@ export const SwiperGridCarousel = ({
|
|||
}, []);
|
||||
|
||||
const handleOnZoomChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
const { isBeginning, isEnd, params, slides } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
|
|
@ -227,7 +228,7 @@ export const SwiperGridCarousel = ({
|
|||
}, []);
|
||||
|
||||
const handleOnReachEnd = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
const { params, slides } = e;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
setPagination({
|
||||
|
|
@ -237,7 +238,7 @@ export const SwiperGridCarousel = ({
|
|||
}, []);
|
||||
|
||||
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
const { params, slides } = e;
|
||||
|
||||
const slideCount = (params.slidesPerView as number | undefined) || 4;
|
||||
setPagination({
|
||||
|
|
@ -279,8 +280,8 @@ export const SwiperGridCarousel = ({
|
|||
|
||||
return (
|
||||
<CarouselContainer
|
||||
ref={containerRef}
|
||||
className="grid-carousel"
|
||||
ref={containerRef}
|
||||
spacing="md"
|
||||
>
|
||||
{title ? (
|
||||
|
|
@ -292,12 +293,7 @@ export const SwiperGridCarousel = ({
|
|||
/>
|
||||
) : null}
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
resizeObserver
|
||||
modules={[Virtual]}
|
||||
slidesPerView={slideCount}
|
||||
spaceBetween={20}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onBeforeInit={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
|
|
@ -305,6 +301,11 @@ export const SwiperGridCarousel = ({
|
|||
onReachEnd={handleOnReachEnd}
|
||||
onSlideChange={handleOnSlideChange}
|
||||
onZoomChange={handleOnZoomChange}
|
||||
ref={swiperRef}
|
||||
resizeObserver
|
||||
slidesPerView={slideCount}
|
||||
spaceBetween={20}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
{...swiperProps}
|
||||
>
|
||||
{slides.map((slideContent, index) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core';
|
||||
import { HoverCardProps, HoverCard as MantineHoverCard } from '@mantine/core';
|
||||
|
||||
export const HoverCard = ({ children, ...props }: HoverCardProps) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import type {
|
||||
TextInputProps as MantineTextInputProps,
|
||||
NumberInputProps as MantineNumberInputProps,
|
||||
PasswordInputProps as MantinePasswordInputProps,
|
||||
FileInputProps as MantineFileInputProps,
|
||||
JsonInputProps as MantineJsonInputProps,
|
||||
NumberInputProps as MantineNumberInputProps,
|
||||
PasswordInputProps as MantinePasswordInputProps,
|
||||
TextareaProps as MantineTextareaProps,
|
||||
TextInputProps as MantineTextInputProps,
|
||||
} from '@mantine/core';
|
||||
|
||||
import {
|
||||
TextInput as MantineTextInput,
|
||||
NumberInput as MantineNumberInput,
|
||||
PasswordInput as MantinePasswordInput,
|
||||
FileInput as MantineFileInput,
|
||||
JsonInput as MantineJsonInput,
|
||||
NumberInput as MantineNumberInput,
|
||||
PasswordInput as MantinePasswordInput,
|
||||
Textarea as MantineTextarea,
|
||||
TextInput as MantineTextInput,
|
||||
} from '@mantine/core';
|
||||
import React, { forwardRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface TextInputProps extends MantineTextInputProps {
|
||||
interface FileInputProps extends MantineFileInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface JsonInputProps extends MantineJsonInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
|
|
@ -35,24 +42,18 @@ interface PasswordInputProps extends MantinePasswordInputProps {
|
|||
width?: number | string;
|
||||
}
|
||||
|
||||
interface FileInputProps extends MantineFileInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface JsonInputProps extends MantineJsonInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface TextareaProps extends MantineTextareaProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface TextInputProps extends MantineTextInputProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
|
||||
& .mantine-TextInput-wrapper {
|
||||
border-color: var(--primary-color);
|
||||
|
|
@ -276,7 +277,7 @@ const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
|
|||
`;
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
({ children, width, maxWidth, ...props }: TextInputProps, ref) => {
|
||||
({ children, maxWidth, width, ...props }: TextInputProps, ref) => {
|
||||
return (
|
||||
<StyledTextInput
|
||||
ref={ref}
|
||||
|
|
@ -291,11 +292,11 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
|||
);
|
||||
|
||||
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
({ children, width, maxWidth, ...props }: NumberInputProps, ref) => {
|
||||
({ children, maxWidth, width, ...props }: NumberInputProps, ref) => {
|
||||
return (
|
||||
<StyledNumberInput
|
||||
ref={ref}
|
||||
hideControls
|
||||
ref={ref}
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
|
|
@ -307,7 +308,7 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
|
|||
);
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
({ children, width, maxWidth, ...props }: PasswordInputProps, ref) => {
|
||||
({ children, maxWidth, width, ...props }: PasswordInputProps, ref) => {
|
||||
return (
|
||||
<StyledPasswordInput
|
||||
ref={ref}
|
||||
|
|
@ -321,7 +322,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
|||
);
|
||||
|
||||
export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>(
|
||||
({ children, width, maxWidth, ...props }: FileInputProps, ref) => {
|
||||
({ children, maxWidth, width, ...props }: FileInputProps, ref) => {
|
||||
return (
|
||||
<StyledFileInput
|
||||
ref={ref}
|
||||
|
|
@ -340,7 +341,7 @@ export const FileInput = forwardRef<HTMLButtonElement, FileInputProps>(
|
|||
);
|
||||
|
||||
export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(
|
||||
({ children, width, maxWidth, ...props }: JsonInputProps, ref) => {
|
||||
({ children, maxWidth, width, ...props }: JsonInputProps, ref) => {
|
||||
return (
|
||||
<StyledJsonInput
|
||||
ref={ref}
|
||||
|
|
@ -354,7 +355,7 @@ export const JsonInput = forwardRef<HTMLTextAreaElement, JsonInputProps>(
|
|||
);
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ children, width, maxWidth, ...props }: TextareaProps, ref) => {
|
||||
({ children, maxWidth, width, ...props }: TextareaProps, ref) => {
|
||||
return (
|
||||
<StyledTextarea
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
ModalProps as MantineModalProps,
|
||||
Stack,
|
||||
Modal as MantineModal,
|
||||
Flex,
|
||||
Group,
|
||||
Modal as MantineModal,
|
||||
ModalProps as MantineModalProps,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { closeAllModals, ContextModalProps } from '@mantine/modals';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
|
||||
export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
||||
|
|
@ -59,12 +60,12 @@ interface ConfirmModalProps {
|
|||
}
|
||||
|
||||
export const ConfirmModal = ({
|
||||
loading,
|
||||
children,
|
||||
disabled,
|
||||
labels,
|
||||
loading,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
}: ConfirmModalProps) => {
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
|
|
@ -80,16 +81,16 @@ export const ConfirmModal = ({
|
|||
<Group position="right">
|
||||
<Button
|
||||
data-focus
|
||||
variant="default"
|
||||
onClick={handleCancel}
|
||||
variant="default"
|
||||
>
|
||||
{labels?.cancel ? labels.cancel : 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
variant="filled"
|
||||
onClick={onConfirm}
|
||||
variant="filled"
|
||||
>
|
||||
{labels?.confirm ? labels.confirm : 'Confirm'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Flex, Group } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const Option = ({ children }: any) => {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Flex, FlexProps } from '@mantine/core';
|
|||
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||
import { ReactNode, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
|
|
@ -49,7 +50,7 @@ const BackgroundImage = styled.div<{ $background: string }>`
|
|||
background: ${(props) => props.$background || 'var(--titlebar-bg)'};
|
||||
`;
|
||||
|
||||
const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
|
||||
const BackgroundImageOverlay = styled.div<{ theme: 'dark' | 'light' }>`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
|
@ -63,7 +64,7 @@ const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
|
|||
`;
|
||||
|
||||
export interface PageHeaderProps
|
||||
extends Omit<FlexProps, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
|
||||
extends Omit<FlexProps, 'onAnimationStart' | 'onDrag' | 'onDragEnd' | 'onDragStart'> {
|
||||
animated?: boolean;
|
||||
backgroundColor?: string;
|
||||
children?: ReactNode;
|
||||
|
|
@ -93,11 +94,11 @@ const variants: Variants = {
|
|||
|
||||
export const PageHeader = ({
|
||||
animated,
|
||||
position,
|
||||
height,
|
||||
backgroundColor,
|
||||
isHidden,
|
||||
children,
|
||||
height,
|
||||
isHidden,
|
||||
position,
|
||||
...props
|
||||
}: PageHeaderProps) => {
|
||||
const ref = useRef(null);
|
||||
|
|
@ -108,9 +109,9 @@ export const PageHeader = ({
|
|||
return (
|
||||
<>
|
||||
<Container
|
||||
ref={ref}
|
||||
$height={height}
|
||||
$position={position}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<Header
|
||||
|
|
@ -132,7 +133,7 @@ export const PageHeader = ({
|
|||
{backgroundColor && (
|
||||
<>
|
||||
<BackgroundImage $background={backgroundColor || 'var(--titlebar-bg)'} />
|
||||
<BackgroundImageOverlay theme={theme as 'light' | 'dark'} />
|
||||
<BackgroundImageOverlay theme={theme as 'dark' | 'light'} />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ReactNode } from 'react';
|
||||
import type { PaperProps as MantinePaperProps } from '@mantine/core';
|
||||
|
||||
import { Paper as MantinePaper } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export interface PaperProps extends MantinePaperProps {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type {
|
||||
PopoverProps as MantinePopoverProps,
|
||||
PopoverDropdownProps as MantinePopoverDropdownProps,
|
||||
PopoverProps as MantinePopoverProps,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { Popover as MantinePopover } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type PopoverProps = MantinePopoverProps;
|
||||
type PopoverDropdownProps = MantinePopoverDropdownProps;
|
||||
type PopoverProps = MantinePopoverProps;
|
||||
|
||||
const StyledPopover = styled(MantinePopover)``;
|
||||
|
||||
|
|
@ -21,13 +22,13 @@ const StyledDropdown = styled(MantinePopover.Dropdown)<PopoverDropdownProps>`
|
|||
export const Popover = ({ children, ...props }: PopoverProps) => {
|
||||
return (
|
||||
<StyledPopover
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transitionProps={{ transition: 'fade' }}
|
||||
withinPortal
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { Group, Stack } from '@mantine/core';
|
||||
import { Select } from '/@/renderer/components/select';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { RiAddFill, RiAddLine, RiDeleteBinFill, RiMore2Line, RiRestartLine } from 'react-icons/ri';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
||||
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
|
||||
import { Select } from '/@/renderer/components/select';
|
||||
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||
import i18n from '/@/i18n/i18n';
|
||||
|
||||
const FILTER_GROUP_OPTIONS_DATA = [
|
||||
{
|
||||
|
|
@ -63,22 +64,22 @@ interface QueryBuilderProps {
|
|||
|
||||
export const QueryBuilder = ({
|
||||
data,
|
||||
filters,
|
||||
groupIndex,
|
||||
level,
|
||||
onAddRule,
|
||||
onDeleteRuleGroup,
|
||||
onDeleteRule,
|
||||
onAddRuleGroup,
|
||||
onChangeType,
|
||||
onChangeField,
|
||||
operators,
|
||||
onChangeOperator,
|
||||
onChangeType,
|
||||
onChangeValue,
|
||||
onClearFilters,
|
||||
onDeleteRule,
|
||||
onDeleteRuleGroup,
|
||||
onResetFilters,
|
||||
operators,
|
||||
playlists,
|
||||
groupIndex,
|
||||
uniqueId,
|
||||
filters,
|
||||
}: QueryBuilderProps) => {
|
||||
const handleAddRule = () => {
|
||||
onAddRule({ groupIndex, level });
|
||||
|
|
@ -92,7 +93,7 @@ export const QueryBuilder = ({
|
|||
onDeleteRuleGroup({ groupIndex, level, uniqueId });
|
||||
};
|
||||
|
||||
const handleChangeType = (value: string | null) => {
|
||||
const handleChangeType = (value: null | string) => {
|
||||
onChangeType({ groupIndex, level, value });
|
||||
};
|
||||
|
||||
|
|
@ -105,17 +106,17 @@ export const QueryBuilder = ({
|
|||
<Select
|
||||
data={FILTER_GROUP_OPTIONS_DATA}
|
||||
maxWidth={175}
|
||||
onChange={handleChangeType}
|
||||
size="sm"
|
||||
value={data.type}
|
||||
width="20%"
|
||||
onChange={handleChangeType}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddRule}
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Add rule' }}
|
||||
variant="default"
|
||||
onClick={handleAddRule}
|
||||
>
|
||||
<RiAddLine size={20} />
|
||||
</Button>
|
||||
|
|
@ -170,10 +171,10 @@ export const QueryBuilder = ({
|
|||
<AnimatePresence initial={false}>
|
||||
{data?.rules?.map((rule: QueryBuilderRule) => (
|
||||
<motion.div
|
||||
key={rule.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
key={rule.uniqueId}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilderOption
|
||||
|
|
@ -182,12 +183,12 @@ export const QueryBuilder = ({
|
|||
groupIndex={groupIndex || []}
|
||||
level={level}
|
||||
noRemove={data?.rules?.length === 1}
|
||||
operators={operators}
|
||||
selectData={playlists}
|
||||
onChangeField={onChangeField}
|
||||
onChangeOperator={onChangeOperator}
|
||||
onChangeValue={onChangeValue}
|
||||
onDeleteRule={onDeleteRule}
|
||||
operators={operators}
|
||||
selectData={playlists}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
|
@ -196,10 +197,10 @@ export const QueryBuilder = ({
|
|||
<AnimatePresence initial={false}>
|
||||
{data.group?.map((group: QueryBuilderGroup, index: number) => (
|
||||
<motion.div
|
||||
key={group.uniqueId}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -25 }}
|
||||
initial={{ opacity: 0, x: -25 }}
|
||||
key={group.uniqueId}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
>
|
||||
<QueryBuilder
|
||||
|
|
@ -207,9 +208,6 @@ export const QueryBuilder = ({
|
|||
filters={filters}
|
||||
groupIndex={[...(groupIndex || []), index]}
|
||||
level={level + 1}
|
||||
operators={operators}
|
||||
playlists={playlists}
|
||||
uniqueId={group.uniqueId}
|
||||
onAddRule={onAddRule}
|
||||
onAddRuleGroup={onAddRuleGroup}
|
||||
onChangeField={onChangeField}
|
||||
|
|
@ -220,6 +218,9 @@ export const QueryBuilder = ({
|
|||
onDeleteRule={onDeleteRule}
|
||||
onDeleteRuleGroup={onDeleteRuleGroup}
|
||||
onResetFilters={onResetFilters}
|
||||
operators={operators}
|
||||
playlists={playlists}
|
||||
uniqueId={group.uniqueId}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Group } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { RiSubtractLine } from 'react-icons/ri';
|
||||
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { NumberInput, TextInput } from '/@/renderer/components/input';
|
||||
import { Select } from '/@/renderer/components/select';
|
||||
|
|
@ -31,62 +32,10 @@ interface QueryOptionProps {
|
|||
selectData?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
const QueryValueInput = ({ onChange, type, data, ...props }: any) => {
|
||||
const QueryValueInput = ({ data, onChange, type, ...props }: any) => {
|
||||
const [numberRange, setNumberRange] = useState([0, 0]);
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue)}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<TextInput
|
||||
size="sm"
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'dateRange':
|
||||
return (
|
||||
<>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [e || 0, numberRange[1]];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
|
||||
maxWidth={81}
|
||||
width="10%"
|
||||
onChange={(e) => {
|
||||
const newRange = [numberRange[0], e || 0];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<Select
|
||||
|
|
@ -98,6 +47,50 @@ const QueryValueInput = ({ onChange, type, data, ...props }: any) => {
|
|||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
return (
|
||||
<TextInput
|
||||
onChange={onChange}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'dateRange':
|
||||
return (
|
||||
<>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
|
||||
maxWidth={81}
|
||||
onChange={(e) => {
|
||||
const newRange = [e || 0, numberRange[1]];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
width="10%"
|
||||
/>
|
||||
<NumberInput
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
|
||||
maxWidth={81}
|
||||
onChange={(e) => {
|
||||
const newRange = [numberRange[0], e || 0];
|
||||
setNumberRange(newRange);
|
||||
onChange(newRange);
|
||||
}}
|
||||
width="10%"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
onChange={onChange}
|
||||
size="sm"
|
||||
{...props}
|
||||
defaultValue={props.defaultValue && Number(props.defaultValue)}
|
||||
/>
|
||||
);
|
||||
case 'playlist':
|
||||
return (
|
||||
<Select
|
||||
|
|
@ -106,6 +99,14 @@ const QueryValueInput = ({ onChange, type, data, ...props }: any) => {
|
|||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
onChange={onChange}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
|
|
@ -115,14 +116,14 @@ const QueryValueInput = ({ onChange, type, data, ...props }: any) => {
|
|||
export const QueryBuilderOption = ({
|
||||
data,
|
||||
filters,
|
||||
level,
|
||||
onDeleteRule,
|
||||
operators,
|
||||
groupIndex,
|
||||
level,
|
||||
noRemove,
|
||||
onChangeField,
|
||||
onChangeOperator,
|
||||
onChangeValue,
|
||||
onDeleteRule,
|
||||
operators,
|
||||
selectData,
|
||||
}: QueryOptionProps) => {
|
||||
const { field, operator, uniqueId, value } = data;
|
||||
|
|
@ -192,51 +193,51 @@ export const QueryBuilderOption = ({
|
|||
spacing="sm"
|
||||
>
|
||||
<Select
|
||||
searchable
|
||||
data={filters}
|
||||
maxWidth={170}
|
||||
onChange={handleChangeField}
|
||||
searchable
|
||||
size="sm"
|
||||
value={field}
|
||||
width="25%"
|
||||
onChange={handleChangeField}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
data={operatorsByFieldType || []}
|
||||
disabled={!field}
|
||||
maxWidth={170}
|
||||
onChange={handleChangeOperator}
|
||||
searchable
|
||||
size="sm"
|
||||
value={operator}
|
||||
width="25%"
|
||||
onChange={handleChangeOperator}
|
||||
/>
|
||||
{field ? (
|
||||
<QueryValueInput
|
||||
data={selectData || []}
|
||||
defaultValue={value}
|
||||
maxWidth={170}
|
||||
onChange={handleChangeValue}
|
||||
size="sm"
|
||||
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
disabled
|
||||
defaultValue={value}
|
||||
disabled
|
||||
maxWidth={170}
|
||||
onChange={handleChangeValue}
|
||||
size="sm"
|
||||
width="25%"
|
||||
onChange={handleChangeValue}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={noRemove}
|
||||
onClick={handleDeleteRule}
|
||||
px={5}
|
||||
size="sm"
|
||||
tooltip={{ label: 'Remove rule' }}
|
||||
variant="default"
|
||||
onClick={handleDeleteRule}
|
||||
>
|
||||
<RiSubtractLine size={20} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { useCallback } from 'react';
|
||||
import { Rating as MantineRating, RatingProps } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledRating = styled(MantineRating)`
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
/* eslint-disable react/display-name */
|
||||
import type { ScrollAreaProps as MantineScrollAreaProps } from '@mantine/core';
|
||||
|
||||
import { ScrollArea as MantineScrollArea } from '@mantine/core';
|
||||
import { useMergedRef } from '@mantine/hooks';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import { CSSProperties, forwardRef, ReactNode, Ref, useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
|
|
@ -51,7 +54,7 @@ const StyledNativeScrollArea = styled.div<{
|
|||
$scrollBarOffset?: string;
|
||||
$windowBarStyle?: Platform;
|
||||
}>`
|
||||
height: 100%;
|
||||
height: calc(100vh - 90px);
|
||||
`;
|
||||
|
||||
export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, ref: Ref<any>) => {
|
||||
|
|
@ -80,10 +83,10 @@ export const NativeScrollArea = forwardRef(
|
|||
(
|
||||
{
|
||||
children,
|
||||
noHeader,
|
||||
pageHeaderProps,
|
||||
scrollBarOffset,
|
||||
scrollHideDelay,
|
||||
noHeader,
|
||||
...props
|
||||
}: NativeScrollAreaProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
|
|
@ -156,9 +159,9 @@ export const NativeScrollArea = forwardRef(
|
|||
/>
|
||||
)}
|
||||
<StyledNativeScrollArea
|
||||
ref={mergedRef}
|
||||
$scrollBarOffset={scrollBarOffset}
|
||||
$windowBarStyle={windowBarStyle}
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { ActionIcon, TextInputProps } from '@mantine/core';
|
||||
import { useFocusWithin, useHotkeys, useMergedRef } from '@mantine/hooks';
|
||||
import { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { RiCloseFill, RiSearchLine } from 'react-icons/ri';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { TextInput } from '/@/renderer/components/input';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
interface SearchInputProps extends TextInputProps {
|
||||
initialWidth?: number;
|
||||
|
|
@ -18,7 +19,7 @@ export const SearchInput = ({
|
|||
openedWidth,
|
||||
...props
|
||||
}: SearchInputProps) => {
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const { focused, ref } = useFocusWithin();
|
||||
const mergedRef = useMergedRef<HTMLInputElement>(ref);
|
||||
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
|
||||
|
||||
|
|
@ -40,6 +41,8 @@ export const SearchInput = ({
|
|||
ref={mergedRef}
|
||||
{...props}
|
||||
icon={showIcon && <RiSearchLine />}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleEscape}
|
||||
rightSection={
|
||||
isOpened ? (
|
||||
<ActionIcon
|
||||
|
|
@ -64,8 +67,6 @@ export const SearchInput = ({
|
|||
},
|
||||
}}
|
||||
width={isOpened ? openedWidth || 150 : initialWidth || 35}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleEscape}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { forwardRef } from 'react';
|
||||
import type { SegmentedControlProps as MantineSegmentedControlProps } from '@mantine/core';
|
||||
|
||||
import { SegmentedControl as MantineSegmentedControl } from '@mantine/core';
|
||||
import { forwardRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type SegmentedControlProps = MantineSegmentedControlProps;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { MultiSelect, MultiSelectProps, Select, SelectProps } from '/@/renderer/components/select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MultiSelect, MultiSelectProps, Select, SelectProps } from '/@/renderer/components/select';
|
||||
|
||||
export const SelectWithInvalidData = ({ data, defaultValue, ...props }: SelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import type {
|
||||
SelectProps as MantineSelectProps,
|
||||
MultiSelectProps as MantineMultiSelectProps,
|
||||
SelectProps as MantineSelectProps,
|
||||
} from '@mantine/core';
|
||||
import { Select as MantineSelect, MultiSelect as MantineMultiSelect } from '@mantine/core';
|
||||
|
||||
import { MultiSelect as MantineMultiSelect, Select as MantineSelect } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export interface SelectProps extends MantineSelectProps {
|
||||
export interface MultiSelectProps extends MantineMultiSelectProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export interface MultiSelectProps extends MantineMultiSelectProps {
|
||||
export interface SelectProps extends MantineSelectProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
|
@ -37,10 +38,9 @@ const StyledSelect = styled(MantineSelect)`
|
|||
}
|
||||
`;
|
||||
|
||||
export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
|
||||
export const Select = ({ maxWidth, width, ...props }: SelectProps) => {
|
||||
return (
|
||||
<StyledSelect
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
|
|
@ -70,6 +70,7 @@ export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
|
|||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
withinPortal
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -92,10 +93,9 @@ const StyledMultiSelect = styled(MantineMultiSelect)`
|
|||
}
|
||||
`;
|
||||
|
||||
export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) => {
|
||||
export const MultiSelect = ({ maxWidth, width, ...props }: MultiSelectProps) => {
|
||||
return (
|
||||
<StyledMultiSelect
|
||||
withinPortal
|
||||
styles={{
|
||||
dropdown: {
|
||||
background: 'var(--dropdown-menu-bg)',
|
||||
|
|
@ -131,6 +131,7 @@ export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) =>
|
|||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
withinPortal
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { SkeletonProps as MantineSkeletonProps } from '@mantine/core';
|
||||
|
||||
import { Skeleton as MantineSkeleton } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { SliderProps as MantineSliderProps } from '@mantine/core';
|
||||
|
||||
import { Slider as MantineSlider } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Center } from '@mantine/core';
|
||||
import type { IconType } from 'react-icons';
|
||||
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiLoader5Fill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { rotating } from '/@/renderer/styles';
|
||||
|
||||
interface SpinnerProps extends IconType {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import clsx from 'clsx';
|
||||
import { HTMLAttributes, ReactNode, useRef, useState } from 'react';
|
||||
|
||||
import styles from './spoiler.module.scss';
|
||||
|
||||
import { useIsOverflow } from '/@/renderer/hooks';
|
||||
|
||||
interface SpoilerProps extends HTMLAttributes<HTMLDivElement> {
|
||||
|
|
@ -9,7 +11,7 @@ interface SpoilerProps extends HTMLAttributes<HTMLDivElement> {
|
|||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: SpoilerProps) => {
|
||||
export const Spoiler = ({ children, defaultOpened, maxHeight, ...props }: SpoilerProps) => {
|
||||
const ref = useRef(null);
|
||||
const isOverflow = useIsOverflow(ref);
|
||||
const [isExpanded, setIsExpanded] = useState(!!defaultOpened);
|
||||
|
|
@ -25,12 +27,12 @@ export const Spoiler = ({ maxHeight, defaultOpened, children, ...props }: Spoile
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={spoilerClassNames}
|
||||
onClick={handleToggleExpand}
|
||||
ref={ref}
|
||||
role="button"
|
||||
style={{ maxHeight: maxHeight ?? '100px', whiteSpace: 'pre-wrap' }}
|
||||
tabIndex={-1}
|
||||
onClick={handleToggleExpand}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { SwitchProps as MantineSwitchProps } from '@mantine/core';
|
||||
|
||||
import { Switch as MantineSwitch } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
|
|||