mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-21 09:56:43 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 652443fe11 | |||
| 4c8894259c | |||
| ac0a24f008 | |||
| 14625a9ef6 | |||
| 173efa9bfd | |||
| e62242824d | |||
| dbd82d46cf | |||
| e4378d1bc9 | |||
| f7ae4faeb0 | |||
| 749899cec0 | |||
| f9b9f9ab06 |
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "1.15.0",
|
||||
"version": "1.15.1",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
@@ -133,7 +133,7 @@
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-player": "^2.16.1",
|
||||
"react-router": "^7.17.0",
|
||||
"react-router": "^7.15.1",
|
||||
"react-split-pane": "^3.2.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-window": "1.8.11",
|
||||
|
||||
Generated
+362
-83
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import id from './locales/id.json';
|
||||
import it from './locales/it.json';
|
||||
import ja from './locales/ja.json';
|
||||
import ko from './locales/ko.json';
|
||||
import lv from './locales/lv.json';
|
||||
import nbNO from './locales/nb-NO.json';
|
||||
import nl from './locales/nl.json';
|
||||
import pl from './locales/pl.json';
|
||||
@@ -51,6 +52,7 @@ const resources = {
|
||||
it: { translation: it },
|
||||
ja: { translation: ja },
|
||||
ko: { translation: ko },
|
||||
lv: { translation: lv },
|
||||
'nb-NO': { translation: nbNO },
|
||||
nl: { translation: nl },
|
||||
pl: { translation: pl },
|
||||
@@ -129,6 +131,10 @@ export const languages = [
|
||||
label: '한국어',
|
||||
value: 'ko',
|
||||
},
|
||||
{
|
||||
label: 'Latviešu',
|
||||
value: 'lv',
|
||||
},
|
||||
{
|
||||
label: 'Nederlands',
|
||||
value: 'nl',
|
||||
|
||||
@@ -550,7 +550,8 @@
|
||||
"selectAll": "Vybrat vše",
|
||||
"goToCurrent": "Přejít na aktuální položku",
|
||||
"collapseAllFolders": "Sbalit všechny složky",
|
||||
"expandAllFolders": "Rozbalit všechny složky"
|
||||
"expandAllFolders": "Rozbalit všechny složky",
|
||||
"createPlaylistFromQueue": "Vytvořit $t(entity.playlist, {\"count\": 1}) z fronty"
|
||||
},
|
||||
"common": {
|
||||
"backward": "Zpátky",
|
||||
@@ -681,7 +682,9 @@
|
||||
"numberOfResults": "{{numberOfResults}} výsledků",
|
||||
"grouping": "Seskupování",
|
||||
"back": "Zpět",
|
||||
"openFolder": "Otevřít složku"
|
||||
"openFolder": "Otevřít složku",
|
||||
"saveAndClose": "Uložit a zavřít",
|
||||
"undo": "Vrátit"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -947,7 +950,8 @@
|
||||
"selectMusicFolder": "Vybrat složku s hudbou",
|
||||
"noMusicFolder": "Není vybrána žádná složka s hudbou",
|
||||
"multipleMusicFolders": "Vybráno {{count}} složek s hudbou",
|
||||
"commandPalette": "Otevřít paletu příkazů"
|
||||
"commandPalette": "Otevřít paletu příkazů",
|
||||
"logout": "Odhlásit se"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
@@ -975,7 +979,8 @@
|
||||
"goToAlbum": "Přejít na $t(entity.album, {\"count\": 1})",
|
||||
"goToAlbumArtist": "Přejít na $t(entity.albumArtist, {\"count\": 1})",
|
||||
"moveItems": "$t(action.moveItems)",
|
||||
"goTo": "Přejít na"
|
||||
"goTo": "Přejít na",
|
||||
"editMetadata": "Upravit metadata"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "Nejpřehrávanější",
|
||||
@@ -1065,7 +1070,27 @@
|
||||
"itemDetail": {
|
||||
"copiedPath": "Cesta úspěšně zkopírována",
|
||||
"copyPath": "Kopírovat cestu do schránky",
|
||||
"openFile": "Zobrazit skladbu ve správci souborů"
|
||||
"openFile": "Zobrazit skladbu ve správci souborů",
|
||||
"tagConfiguration": "Nastavení štítků",
|
||||
"addTagConfig": "Přidat štítek…",
|
||||
"multiValueToggle": "Více hodnot",
|
||||
"autocompleteSource": "Automatické dokončení zdroje",
|
||||
"customValues": "Vlastní hodnoty",
|
||||
"addCustomValue": "Přidat vlastní hodnotu…",
|
||||
"serverSuggestions": "Server",
|
||||
"fileNotWritable": "Soubor není přístupný nebo zapisovatelný",
|
||||
"triggerRescan": "Spustit opětovný sken knihovny po uložení",
|
||||
"addField": "Přidat pole…",
|
||||
"emptyFields": "Pole nemohou být prázdná",
|
||||
"tagsTab": "Štítky",
|
||||
"artworkTab": "Obal",
|
||||
"removeArtwork": "Odstranit obal",
|
||||
"noArtwork": "Žádný obal",
|
||||
"multipleValues": "(Více hodnot)",
|
||||
"multipleArtworks": "Více obalů",
|
||||
"noLocalSongs": "Nenalezeny žádné skladby s lokálními cestami k souborům",
|
||||
"readPartialFailure": "Nepodařilo se přečíst metadata {{count}} z {{total}} souborů",
|
||||
"writePartialFailure": "Nepodařilo se uložit {{count}} soubor(ů)"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "Změna pořadí povolena pouze při řazení podle ID"
|
||||
@@ -1218,6 +1243,9 @@
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "Stanice rádia úspěšně upravena"
|
||||
},
|
||||
"createPrefilledPlaylist": {
|
||||
"title": "Vytvořit $t(entity.playlist, {\"count\": 1}) z vybraných skladeb"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -173,7 +173,11 @@
|
||||
"gridRows": "Grid rows",
|
||||
"tableColumns": "Table columns",
|
||||
"itemsMore": "{{count}} more",
|
||||
"newVersionAvailable": "A new version is available"
|
||||
"lastScan": "Last scan {{date}}",
|
||||
"newVersionAvailable": "A new version is available",
|
||||
"scanFolderCount": "{{count}} folders",
|
||||
"scanItemCount": "{{count}} items",
|
||||
"scanningLibrary": "Scanning library…"
|
||||
},
|
||||
"entity": {
|
||||
"album_one": "Album",
|
||||
|
||||
@@ -803,7 +803,8 @@
|
||||
"selectMusicFolder": "Seleccionar carpeta de música",
|
||||
"noMusicFolder": "Ninguna carpeta de música seleccionada",
|
||||
"multipleMusicFolders": "{{count}} carpetas de música seleccionadas",
|
||||
"commandPalette": "Abrir paleta de comandos"
|
||||
"commandPalette": "Abrir paleta de comandos",
|
||||
"logout": "Cerrar sesión"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
|
||||
+30
-12
@@ -207,7 +207,7 @@
|
||||
"savePlayQueue_description": "アプリケーション終了時に再生キューを保存し、アプリケーション開始時に復元します",
|
||||
"useSystemTheme": "システムテーマを使用",
|
||||
"webAudio": "Web Audio を使用する",
|
||||
"mediaSession_description": "Media Session の統合を有効にし、システムボリュームオーバーレイとロック画面にメディアコントロールとメタデータを表示します",
|
||||
"mediaSession_description": "Media Session の統合を有効にし、システムボリュームオーバーレイとロック画面にメディアコントロールとメタデータを表示します。Web Audio Player が必要です。",
|
||||
"mediaSession": "Media Session を有効にする",
|
||||
"startMinimized_description": "システムトレイでアプリケーションを起動する",
|
||||
"startMinimized": "最小化して起動する",
|
||||
@@ -317,7 +317,7 @@
|
||||
"exportImportSettings_importModalTitle": "Feishin 設定をインポート",
|
||||
"exportImportSettings_importBtn": "設定をインポート",
|
||||
"autoDJ": "自動 DJ",
|
||||
"autoDJ_itemCount_description": "自動 DJ が有効なときにキューに追加しようとした曲数",
|
||||
"autoDJ_itemCount_description": "キューに追加しようとした曲数",
|
||||
"autoDJ_itemCount": "曲数",
|
||||
"autoDJ_timing": "タイミング",
|
||||
"autoDJ_timing_description": "自動 DJ が作動するまでのキューに残っている曲数",
|
||||
@@ -434,7 +434,7 @@
|
||||
"hotkey_listShowPlayingSong": "再生中の曲をリストに表示",
|
||||
"autoDJ_mode": "モード",
|
||||
"autoDJ_mode_albums": "アルバム",
|
||||
"autoDJ_mode_description": "キューに曲を追加するか、アルバム全体を追加するかを選択してください。",
|
||||
"autoDJ_mode_description": "曲またはアルバム全体をキューに追加するかを選択してください",
|
||||
"autoDJ_mode_songs": "曲",
|
||||
"autoDJ_enabled": "Auto DJを有効にする",
|
||||
"autoDJ_albumStrategy": "アルバム選択モード",
|
||||
@@ -509,7 +509,8 @@
|
||||
"addOrRemoveFromSelection": "選択に追加または選択から除外",
|
||||
"goToCurrent": "現在の項目へ移動",
|
||||
"collapseAllFolders": "すべてのフォルダーを折りたたむ",
|
||||
"expandAllFolders": "すべてのフォルダーを展開する"
|
||||
"expandAllFolders": "すべてのフォルダーを展開する",
|
||||
"createPlaylistFromQueue": "キューから $t(entity.playlist, {\"count\": 1}) を作成"
|
||||
},
|
||||
"common": {
|
||||
"backward": "逆行",
|
||||
@@ -632,7 +633,9 @@
|
||||
"numberOfResults": "{{numberOfResults}} 件の結果",
|
||||
"grouping": "グループ化",
|
||||
"back": "戻る",
|
||||
"openFolder": "フォルダーを開く"
|
||||
"openFolder": "フォルダーを開く",
|
||||
"saveAndClose": "保存して閉じる",
|
||||
"undo": "元に戻す"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
@@ -861,13 +864,20 @@
|
||||
"dynamicIsImage": "背景画像を有効にする",
|
||||
"lyricOffset": "歌詞のオフセット (ms)",
|
||||
"lyricOpacityNonActive": "非アクティブな歌詞の不透明度",
|
||||
"lyricScaleNonActive": "非アクティブな歌詞のスケール"
|
||||
"lyricScaleNonActive": "非アクティブな歌詞のスケール",
|
||||
"lyricLineLeadTime": "行を読み込む時間 (ms)",
|
||||
"lyricPaddingLeft": "歌詞の左側のパディング (%)",
|
||||
"lyricPaddingRight": "歌詞の右側のパディング (%)"
|
||||
},
|
||||
"upNext": "次へ",
|
||||
"lyrics": "歌詞",
|
||||
"related": "関連",
|
||||
"visualizer": "ビジュアライザー",
|
||||
"noLyrics": "歌詞が見つかりません"
|
||||
"noLyrics": "歌詞が見つかりません",
|
||||
"lyricLayers": "歌詞レイヤー",
|
||||
"lyricLanguage": "言語",
|
||||
"showPronunciation": "発音",
|
||||
"showTranslation": "翻訳"
|
||||
},
|
||||
"appMenu": {
|
||||
"selectServer": "サーバーを選択",
|
||||
@@ -885,7 +895,8 @@
|
||||
"selectMusicFolder": "音楽フォルダーを選択",
|
||||
"noMusicFolder": "音楽フォルダーが選択されていません",
|
||||
"commandPalette": "コマンドパレットを開く",
|
||||
"multipleMusicFolders": "{{count}} 個の音楽フォルダーが選択されました"
|
||||
"multipleMusicFolders": "{{count}} 個の音楽フォルダーが選択されました",
|
||||
"logout": "ログアウト"
|
||||
},
|
||||
"contextMenu": {
|
||||
"addToPlaylist": "$t(action.addToPlaylist)",
|
||||
@@ -913,7 +924,8 @@
|
||||
"showDetails": "情報を取得する",
|
||||
"playShuffled": "$t(player.shuffle)",
|
||||
"moveItems": "$t(action.moveItems)",
|
||||
"goTo": "移動"
|
||||
"goTo": "移動",
|
||||
"editMetadata": "メタデータを編集"
|
||||
},
|
||||
"home": {
|
||||
"mostPlayed": "最も多く再生",
|
||||
@@ -1011,7 +1023,9 @@
|
||||
"itemDetail": {
|
||||
"openFile": "ファイルマネージャーでトラックを表示する",
|
||||
"copyPath": "パスをクリップボードにコピーする",
|
||||
"copiedPath": "パスが正常にコピーされました"
|
||||
"copiedPath": "パスが正常にコピーされました",
|
||||
"tagConfiguration": "タグ設定",
|
||||
"addTagConfig": "タグを追加…"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "ID によるソート時のみ並べ替えが可能です"
|
||||
@@ -1069,14 +1083,15 @@
|
||||
"input_preferInstantMix": "インスタントミックスを優先する",
|
||||
"input_preferRemoteUrl": "公開 URL を優先する",
|
||||
"input_remoteUrl": "公開 URL",
|
||||
"input_remoteUrlPlaceholder": "オプション: 外部機能用の公開 URL"
|
||||
"input_remoteUrlPlaceholder": "オプション: 外部機能用の公開 URL",
|
||||
"input_passwordNoSSO": "リバースプロキシ(SSO)認証はサポートされていません。ユーザー名 / パスワード認証と Subsonic 認証のみがサポートされています"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
"success": "$t(entity.trackWithCount, {\"count\": {{message}} }) を $t(entity.playlistWithCount, {\"count\": {{numOfPlaylists}} }) に追加しました",
|
||||
"title": "$t(entity.playlist, {\"count\": 1}) に追加",
|
||||
"input_skipDuplicates": "重複をスキップ",
|
||||
"input_playlists": "$t(entity.playlist, {\"count\": 2})",
|
||||
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} を作成する",
|
||||
"create": "$t(entity.playlist, {\"count\": 1}) {{playlist}} を作成",
|
||||
"searchOrCreate": "$t(entity.playlist, {\"count\": 2}) を検索するか、入力して新しいプレイリストを作成してください",
|
||||
"noneAdded": "$t(entity.playlist, {\"count\": 1}) {{playlist}} にトラックが追加されませんでした"
|
||||
},
|
||||
@@ -1155,6 +1170,9 @@
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "ラジオ局の更新に成功しました"
|
||||
},
|
||||
"createPrefilledPlaylist": {
|
||||
"title": "選択した曲から $t(entity.playlist, {\"count\": 1}) を作成"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
+1435
-1
File diff suppressed because it is too large
Load Diff
@@ -560,7 +560,8 @@
|
||||
"noMusicFolder": "Не выбрана папка с музыкой",
|
||||
"multipleMusicFolders": "Выбрано {{count}} папок с музыкой",
|
||||
"commandPalette": "Открыть командную строку",
|
||||
"settings": "$t(common.setting, {\"count\": 2})"
|
||||
"settings": "$t(common.setting, {\"count\": 2})",
|
||||
"logout": "Выйти"
|
||||
},
|
||||
"manageServers": {
|
||||
"title": "Управление серверами",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"selectMusicFolder": "選擇媒體庫",
|
||||
"noMusicFolder": "未選取任何媒體庫",
|
||||
"multipleMusicFolders": "已選取 {{count}} 個媒體庫",
|
||||
"commandPalette": "開啟命令面板"
|
||||
"commandPalette": "開啟命令面板",
|
||||
"logout": "登出"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
|
||||
@@ -285,7 +285,7 @@ export async function writeFilesTags(
|
||||
}
|
||||
|
||||
for (const key of propertyRemovals) {
|
||||
delete properties[canonicalizePropertyKey(key)];
|
||||
properties[canonicalizePropertyKey(key)] = [];
|
||||
}
|
||||
|
||||
file.setProperties(properties);
|
||||
|
||||
@@ -644,54 +644,6 @@ async function createWindow(first = true): Promise<void> {
|
||||
return mainWindow?.webContents.session.clearCache();
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'app-check-for-updates',
|
||||
async (): Promise<{ updateAvailable: boolean; version?: string }> => {
|
||||
if (disableAutoUpdates()) {
|
||||
console.log('Auto updates are disabled');
|
||||
return { updateAvailable: false };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Checking for updates');
|
||||
const effectiveChannel = store.get('release_channel') as string;
|
||||
let result: null | UpdateCheckResult;
|
||||
let updater: UpdaterInstance;
|
||||
|
||||
if (effectiveChannel === 'alpha') {
|
||||
const best = await checkAllChannelsAndGetBest();
|
||||
result = best.result;
|
||||
updater = best.updater;
|
||||
} else {
|
||||
updater = configureAndGetUpdater();
|
||||
result = await updater.checkForUpdates();
|
||||
}
|
||||
|
||||
const updateAvailable = result?.isUpdateAvailable ?? false;
|
||||
console.log('Update available:', updateAvailable);
|
||||
if (updateAvailable && store.get('disable_auto_updates') !== true) {
|
||||
if (isMacOS()) {
|
||||
getMainWindow()?.webContents.send(
|
||||
'update-available',
|
||||
result?.updateInfo?.version,
|
||||
);
|
||||
} else {
|
||||
console.log('Downloading update');
|
||||
updater.downloadUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
version: result?.updateInfo?.version,
|
||||
};
|
||||
} catch {
|
||||
console.log('Error checking for updates');
|
||||
return { updateAvailable: false };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('app-restart', () => {
|
||||
// Fix for .AppImage
|
||||
if (process.env.APPIMAGE) {
|
||||
|
||||
@@ -612,6 +612,18 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
getScanStatus(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`${i18n.t('error.apiRouteError')}: getScanStatus`);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'getScanStatus',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
getServerInfo(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
||||
@@ -206,6 +206,14 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getScheduledTasks: {
|
||||
method: 'GET',
|
||||
path: 'ScheduledTasks',
|
||||
responses: {
|
||||
200: jfType._response.scheduledTasks,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getServerInfo: {
|
||||
method: 'GET',
|
||||
path: 'system/info',
|
||||
|
||||
@@ -1161,6 +1161,34 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
};
|
||||
},
|
||||
getRoles: async () => [],
|
||||
getScanStatus: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getScheduledTasks();
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get scan status');
|
||||
}
|
||||
|
||||
const task =
|
||||
res.body.find((t) => t.Key === 'RefreshLibrary') ||
|
||||
res.body.find((t) => t.Name === 'Scan Media Library');
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
count: 0,
|
||||
folderCount: 0,
|
||||
scanning: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
count: 0,
|
||||
folderCount: 0,
|
||||
lastScan: task.LastExecutionResult?.EndTimeUtc ?? undefined,
|
||||
scanning: task.State === 'Running' || task.State === 'Cancelling',
|
||||
};
|
||||
},
|
||||
getServerInfo: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
|
||||
@@ -723,6 +723,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
getRandomSongList: SubsonicController.getRandomSongList,
|
||||
getRoles: async ({ apiClientProps }) =>
|
||||
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
|
||||
getScanStatus: SubsonicController.getScanStatus,
|
||||
getServerInfo: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ export const queryKeys: Record<
|
||||
},
|
||||
server: {
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
scanStatus: (serverId: string) => [serverId, 'server', 'scanStatus'] as const,
|
||||
},
|
||||
songs: {
|
||||
albumRadio: (serverId: string, query?: AlbumRadioQuery) => {
|
||||
|
||||
@@ -1355,6 +1355,22 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
final.splice(0, 0, { label: 'all artists', value: '' });
|
||||
return final;
|
||||
},
|
||||
getScanStatus: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getScanStatus({ query: {} });
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get scan status');
|
||||
}
|
||||
|
||||
return {
|
||||
count: res.body.scanStatus.count,
|
||||
folderCount: res.body.scanStatus.folderCount,
|
||||
lastScan: res.body.scanStatus.lastScan,
|
||||
scanning: res.body.scanStatus.scanning,
|
||||
};
|
||||
},
|
||||
getServerInfo: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export type EventMap = {
|
||||
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
|
||||
PLAYLIST_REORDER: PlaylistReorderEventPayload;
|
||||
QUEUE_RESTORED: QueueRestoredEventPayload;
|
||||
TAG_EDITED: TagEditedEventPayload;
|
||||
USER_FAVORITE: UserFavoriteEventPayload;
|
||||
USER_RATING: UserRatingEventPayload;
|
||||
};
|
||||
@@ -79,6 +80,12 @@ export type QueueRestoredEventPayload = {
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type TagEditedEventPayload = {
|
||||
id: string[];
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
};
|
||||
|
||||
export type UserFavoriteEventPayload = {
|
||||
favorite: boolean;
|
||||
id: string[];
|
||||
|
||||
@@ -11,3 +11,7 @@
|
||||
padding-left: var(--lyric-padding-left, 0%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface LyricsScrollContentProps {
|
||||
gap?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
preview?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
@@ -20,25 +21,35 @@ export const LyricsScrollContent = ({
|
||||
gap,
|
||||
paddingLeft = 0,
|
||||
paddingRight = 0,
|
||||
preview = false,
|
||||
style,
|
||||
}: LyricsScrollContentProps) => {
|
||||
const contentStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
'--lyric-padding-left': `${paddingLeft}%`,
|
||||
'--lyric-padding-right': `${paddingRight}%`,
|
||||
const contentStyle = useMemo(() => {
|
||||
if (preview) {
|
||||
return {
|
||||
gap: gap !== undefined ? `${gap}px` : undefined,
|
||||
paddingBottom: bottomScrollPadding,
|
||||
paddingLeft: `${paddingLeft}%`,
|
||||
paddingRight: `${paddingRight}%`,
|
||||
paddingTop: '10vh',
|
||||
padding: 0,
|
||||
...style,
|
||||
}) as React.CSSProperties,
|
||||
[bottomScrollPadding, gap, paddingLeft, paddingRight, style],
|
||||
);
|
||||
} as React.CSSProperties;
|
||||
}
|
||||
|
||||
return {
|
||||
'--lyric-padding-left': `${paddingLeft}%`,
|
||||
'--lyric-padding-right': `${paddingRight}%`,
|
||||
gap: gap !== undefined ? `${gap}px` : undefined,
|
||||
paddingBottom: bottomScrollPadding,
|
||||
paddingLeft: `${paddingLeft}%`,
|
||||
paddingRight: `${paddingRight}%`,
|
||||
paddingTop: '10vh',
|
||||
...style,
|
||||
} as React.CSSProperties;
|
||||
}, [bottomScrollPadding, gap, paddingLeft, paddingRight, preview, style]);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.content, className)} style={contentStyle}>
|
||||
<div
|
||||
className={clsx(styles.content, preview && styles.preview, className)}
|
||||
style={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,22 +33,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-preview {
|
||||
:global(.synchronized-lyrics) {
|
||||
height: auto !important;
|
||||
padding: 1rem 0 !important;
|
||||
overflow: visible !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-content-wrapper {
|
||||
:global(> div) {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
padding: 1rem 0 !important;
|
||||
overflow: visible !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,6 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
|
||||
{selectedResult && (
|
||||
<Stack style={{ flex: 1, height: '100%', minHeight: 0, overflow: 'hidden' }}>
|
||||
<ScrollArea
|
||||
className={styles['lyrics-preview']}
|
||||
style={{
|
||||
height: '100%',
|
||||
paddingRight: '1rem',
|
||||
@@ -255,46 +254,42 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch
|
||||
{isPreviewLoading ? (
|
||||
<Spinner container />
|
||||
) : previewData ? (
|
||||
<div
|
||||
className={styles['lyrics-content-wrapper']}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{Array.isArray(previewData) ? (
|
||||
lyricsHasWordCues(previewData) ? (
|
||||
<SynchronizedKaraokeLyrics
|
||||
style={{ padding: 0 }}
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as SynchronizedLyricsProps)}
|
||||
/>
|
||||
) : (
|
||||
<SynchronizedLyrics
|
||||
style={{ padding: 0 }}
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as SynchronizedLyricsProps)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UnsynchronizedLyrics
|
||||
Array.isArray(previewData) ? (
|
||||
lyricsHasWordCues(previewData) ? (
|
||||
<SynchronizedKaraokeLyrics
|
||||
preview
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as UnsynchronizedLyricsProps)}
|
||||
} as SynchronizedLyricsProps)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SynchronizedLyrics
|
||||
preview
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as SynchronizedLyricsProps)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UnsynchronizedLyrics
|
||||
preview
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as UnsynchronizedLyricsProps)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Center>
|
||||
<Text isMuted>{t('page.fullscreenPlayer.noLyrics')}</Text>
|
||||
|
||||
@@ -7,3 +7,11 @@
|
||||
overflow: hidden auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
padding: 1rem 0;
|
||||
overflow: visible;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface SynchronizedKaraokeLyricsProps extends Omit<FullLyricsMetadata,
|
||||
extraOverlayLyrics?: SynchronizedLyricsData[];
|
||||
lyrics: SynchronizedLyricsData;
|
||||
offsetMs?: number;
|
||||
preview?: boolean;
|
||||
pronunciationLyrics?: null | SynchronizedLyricsData;
|
||||
romajiLyrics?: null | SynchronizedLyricsData;
|
||||
settingsKey?: string;
|
||||
@@ -44,6 +45,8 @@ export interface SynchronizedKaraokeLyricsProps extends Omit<FullLyricsMetadata,
|
||||
}
|
||||
|
||||
const SEEK_DETECT_THRESHOLD_MS = 500;
|
||||
const PREVIEW_FONT_SIZE = 20;
|
||||
const PREVIEW_GAP = 20;
|
||||
|
||||
export const SynchronizedKaraokeLyrics = ({
|
||||
agents,
|
||||
@@ -52,6 +55,7 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
lyrics,
|
||||
name,
|
||||
offsetMs,
|
||||
preview = false,
|
||||
pronunciationLyrics,
|
||||
romajiLyrics,
|
||||
settingsKey = 'default',
|
||||
@@ -77,6 +81,11 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
showScrollbar,
|
||||
} = useSynchronizedLyricsBase(settingsKey, offsetMs);
|
||||
|
||||
const effectiveFontSize = preview ? PREVIEW_FONT_SIZE : settings.fontSize;
|
||||
const effectiveGap = preview ? PREVIEW_GAP : settings.gap;
|
||||
const effectivePaddingLeft = preview ? 0 : settings.paddingLeft;
|
||||
const effectivePaddingRight = preview ? 0 : settings.paddingRight;
|
||||
|
||||
const normalizedLyrics = useMemo(() => normalizeLyrics(lyrics), [lyrics]);
|
||||
const rafRef = useRef<null | number>(null);
|
||||
const statusRef = useRef(usePlayerStoreBase.getState().player.status);
|
||||
@@ -97,13 +106,13 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
containerRef,
|
||||
followRef,
|
||||
followScrollAlignmentRef,
|
||||
fontSize: settings.fontSize,
|
||||
gap: settings.gap,
|
||||
fontSize: effectiveFontSize,
|
||||
gap: effectiveGap,
|
||||
lineIdPrefix: 'karaoke-line',
|
||||
lineLeadTimeMsRef,
|
||||
lyrics: normalizedLyrics,
|
||||
paddingLeft: settings.paddingLeft,
|
||||
paddingRight: settings.paddingRight,
|
||||
paddingLeft: effectivePaddingLeft,
|
||||
paddingRight: effectivePaddingRight,
|
||||
scrollContainerId: LYRICS_SCROLL_CONTAINER_ID,
|
||||
});
|
||||
|
||||
@@ -328,7 +337,11 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, 'synchronized-karaoke-lyrics overlay-scrollbar')}
|
||||
className={clsx(
|
||||
styles.container,
|
||||
preview && styles.preview,
|
||||
'synchronized-karaoke-lyrics overlay-scrollbar',
|
||||
)}
|
||||
id={LYRICS_SCROLL_CONTAINER_ID}
|
||||
onClick={handleContainerClick}
|
||||
onMouseEnter={showScrollbar}
|
||||
@@ -337,15 +350,16 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
style={{ ...containerStyle, ...style }}
|
||||
>
|
||||
<LyricsScrollContent
|
||||
gap={settings.gap}
|
||||
paddingLeft={settings.paddingLeft}
|
||||
paddingRight={settings.paddingRight}
|
||||
gap={effectiveGap}
|
||||
paddingLeft={effectivePaddingLeft}
|
||||
paddingRight={effectivePaddingRight}
|
||||
preview={preview}
|
||||
>
|
||||
{settings.showProvider && source && (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-credit"
|
||||
fontSize={settings.fontSize}
|
||||
fontSize={effectiveFontSize}
|
||||
text={`${source}`}
|
||||
/>
|
||||
)}
|
||||
@@ -353,7 +367,7 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-credit"
|
||||
fontSize={settings.fontSize}
|
||||
fontSize={effectiveFontSize}
|
||||
text={`${name} — ${artist}`}
|
||||
/>
|
||||
)}
|
||||
@@ -396,7 +410,7 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized"
|
||||
data-lyric-time={lineStartMs}
|
||||
fontSize={settings.fontSize}
|
||||
fontSize={effectiveFontSize}
|
||||
id={`karaoke-line-${idx}`}
|
||||
key={idx}
|
||||
romajiText={pronunciationText}
|
||||
@@ -414,7 +428,7 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
cueLines={rawLine.cueLines}
|
||||
data-lyric-time={lineStartMs}
|
||||
extraOverlays={extraOverlays}
|
||||
fontSize={settings.fontSize}
|
||||
fontSize={effectiveFontSize}
|
||||
id={`karaoke-line-${idx}`}
|
||||
key={idx}
|
||||
lineIndex={idx}
|
||||
|
||||
@@ -8,3 +8,11 @@
|
||||
word-break: break-all;
|
||||
transform: translateY(-2rem);
|
||||
}
|
||||
|
||||
.preview {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
padding: 1rem 0;
|
||||
overflow: visible;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyric
|
||||
extraOverlayLyrics?: SynchronizedLyricsData[];
|
||||
lyrics: SynchronizedLyricsData;
|
||||
offsetMs?: number;
|
||||
preview?: boolean;
|
||||
pronunciationLyrics?: null | SynchronizedLyricsData;
|
||||
romajiLyrics?: null | SynchronizedLyricsData;
|
||||
settingsKey?: string;
|
||||
@@ -38,12 +39,15 @@ export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyric
|
||||
}
|
||||
|
||||
const SEEK_DETECT_THRESHOLD_MS = 500;
|
||||
const PREVIEW_FONT_SIZE = 20;
|
||||
const PREVIEW_GAP = 20;
|
||||
|
||||
export const SynchronizedLyrics = ({
|
||||
artist,
|
||||
lyrics,
|
||||
name,
|
||||
offsetMs,
|
||||
preview = false,
|
||||
pronunciationLyrics,
|
||||
romajiLyrics,
|
||||
settingsKey = 'default',
|
||||
@@ -68,6 +72,11 @@ export const SynchronizedLyrics = ({
|
||||
showScrollbar,
|
||||
} = useSynchronizedLyricsBase(settingsKey, offsetMs);
|
||||
|
||||
const effectiveFontSize = preview ? PREVIEW_FONT_SIZE : settings.fontSize;
|
||||
const effectiveGap = preview ? PREVIEW_GAP : settings.gap;
|
||||
const effectivePaddingLeft = preview ? 0 : settings.paddingLeft;
|
||||
const effectivePaddingRight = preview ? 0 : settings.paddingRight;
|
||||
|
||||
const normalizedLyrics = useMemo(() => normalizeLyrics(lyrics), [lyrics]);
|
||||
const rafRef = useRef<null | number>(null);
|
||||
const statusRef = useRef(usePlayerStoreBase.getState().player.status);
|
||||
@@ -83,13 +92,13 @@ export const SynchronizedLyrics = ({
|
||||
containerRef,
|
||||
followRef,
|
||||
followScrollAlignmentRef,
|
||||
fontSize: settings.fontSize,
|
||||
gap: settings.gap,
|
||||
fontSize: effectiveFontSize,
|
||||
gap: effectiveGap,
|
||||
lineIdPrefix: 'lyric',
|
||||
lineLeadTimeMsRef,
|
||||
lyrics: normalizedLyrics,
|
||||
paddingLeft: settings.paddingLeft,
|
||||
paddingRight: settings.paddingRight,
|
||||
paddingLeft: effectivePaddingLeft,
|
||||
paddingRight: effectivePaddingRight,
|
||||
scrollContainerId: LYRICS_SCROLL_CONTAINER_ID,
|
||||
});
|
||||
|
||||
@@ -240,7 +249,11 @@ export const SynchronizedLyrics = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, 'synchronized-lyrics overlay-scrollbar')}
|
||||
className={clsx(
|
||||
styles.container,
|
||||
preview && styles.preview,
|
||||
'synchronized-lyrics overlay-scrollbar',
|
||||
)}
|
||||
id={LYRICS_SCROLL_CONTAINER_ID}
|
||||
onClick={handleContainerClick}
|
||||
onMouseEnter={showScrollbar}
|
||||
@@ -249,15 +262,16 @@ export const SynchronizedLyrics = ({
|
||||
style={{ ...containerStyle, ...style }}
|
||||
>
|
||||
<LyricsScrollContent
|
||||
gap={settings.gap}
|
||||
paddingLeft={settings.paddingLeft}
|
||||
paddingRight={settings.paddingRight}
|
||||
gap={effectiveGap}
|
||||
paddingLeft={effectivePaddingLeft}
|
||||
paddingRight={effectivePaddingRight}
|
||||
preview={preview}
|
||||
>
|
||||
{settings.showProvider && source && (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-credit"
|
||||
fontSize={settings.fontSize}
|
||||
fontSize={effectiveFontSize}
|
||||
text={`${source}`}
|
||||
/>
|
||||
)}
|
||||
@@ -265,7 +279,7 @@ export const SynchronizedLyrics = ({
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-credit"
|
||||
fontSize={settings.fontSize}
|
||||
fontSize={effectiveFontSize}
|
||||
text={`${name} — ${artist}`}
|
||||
/>
|
||||
)}
|
||||
@@ -290,7 +304,7 @@ export const SynchronizedLyrics = ({
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized"
|
||||
data-lyric-time={lineStartMs}
|
||||
fontSize={settings.fontSize}
|
||||
fontSize={effectiveFontSize}
|
||||
id={`lyric-${idx}`}
|
||||
key={idx}
|
||||
romajiText={pronunciationText}
|
||||
|
||||
@@ -7,3 +7,11 @@
|
||||
overflow: hidden auto;
|
||||
transform: translateY(-2rem);
|
||||
}
|
||||
|
||||
.preview {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
padding: 1rem 0;
|
||||
overflow: visible;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import styles from './unsynchronized-lyrics.module.css';
|
||||
@@ -9,15 +10,20 @@ import { FullLyricsMetadata } from '/@/shared/types/domain-types';
|
||||
|
||||
export interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
lyrics: string;
|
||||
preview?: boolean;
|
||||
romajiLyrics?: null | string;
|
||||
settingsKey?: string;
|
||||
translatedLyrics?: null | string;
|
||||
}
|
||||
|
||||
const PREVIEW_FONT_SIZE = 20;
|
||||
const PREVIEW_GAP = 20;
|
||||
|
||||
export const UnsynchronizedLyrics = ({
|
||||
artist,
|
||||
lyrics,
|
||||
name,
|
||||
preview = false,
|
||||
romajiLyrics,
|
||||
settingsKey = 'default',
|
||||
source,
|
||||
@@ -27,14 +33,16 @@ export const UnsynchronizedLyrics = ({
|
||||
const displaySettings = useLyricsDisplaySettings(settingsKey);
|
||||
const settings = {
|
||||
...lyricsSettings,
|
||||
fontSizeUnsync:
|
||||
displaySettings.fontSizeUnsync && displaySettings.fontSizeUnsync !== 0
|
||||
? displaySettings.fontSizeUnsync
|
||||
: 24,
|
||||
gapUnsync:
|
||||
displaySettings.gapUnsync && displaySettings.gapUnsync !== 0
|
||||
? displaySettings.gapUnsync
|
||||
: 24,
|
||||
fontSizeUnsync: preview
|
||||
? PREVIEW_FONT_SIZE
|
||||
: displaySettings.fontSizeUnsync && displaySettings.fontSizeUnsync !== 0
|
||||
? displaySettings.fontSizeUnsync
|
||||
: 24,
|
||||
gapUnsync: preview
|
||||
? PREVIEW_GAP
|
||||
: displaySettings.gapUnsync && displaySettings.gapUnsync !== 0
|
||||
? displaySettings.gapUnsync
|
||||
: 24,
|
||||
};
|
||||
const lines = useMemo(() => {
|
||||
return lyrics.split('\n');
|
||||
@@ -49,12 +57,13 @@ export const UnsynchronizedLyrics = ({
|
||||
}, [romajiLyrics]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={clsx(styles.container, preview && styles.preview)}>
|
||||
<LyricsScrollContent
|
||||
bottomScrollPadding="6vh"
|
||||
gap={settings.gapUnsync}
|
||||
paddingLeft={displaySettings.paddingLeft ?? 0}
|
||||
paddingRight={displaySettings.paddingRight ?? 0}
|
||||
paddingLeft={preview ? 0 : (displaySettings.paddingLeft ?? 0)}
|
||||
paddingRight={preview ? 0 : (displaySettings.paddingRight ?? 0)}
|
||||
preview={preview}
|
||||
>
|
||||
{settings.showProvider && source && (
|
||||
<LyricLine
|
||||
|
||||
@@ -185,9 +185,9 @@ export const useScrobble = () => {
|
||||
// Because Jellyfin uses the stop event for submission, we need to send another
|
||||
// progress update after submission so that the song continues to progress in the dashboard
|
||||
const shouldSendProgress =
|
||||
song._serverType !== ServerType.JELLYFIN ||
|
||||
activeSong?._uniqueId !== song._uniqueId ||
|
||||
status !== PlayerStatus.PLAYING;
|
||||
song._serverType === ServerType.JELLYFIN &&
|
||||
activeSong?._uniqueId === song._uniqueId &&
|
||||
status === PlayerStatus.PLAYING;
|
||||
|
||||
if (!shouldSendProgress) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { UpdateAvailableButton } from '/@/renderer/features/settings/components/update-available-button';
|
||||
import { useSettingSearchContext } from '/@/renderer/features/settings/context/search-context';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
@@ -14,9 +15,10 @@ import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
export type SettingsHeaderProps = {
|
||||
setSearch: (search: string) => void;
|
||||
showUpdateAvailable?: boolean;
|
||||
};
|
||||
|
||||
export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {
|
||||
export const SettingsHeader = ({ setSearch, showUpdateAvailable }: SettingsHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { reset } = useSettingsStoreActions();
|
||||
const search = useSettingSearchContext();
|
||||
@@ -48,6 +50,7 @@ export const SettingsHeader = ({ setSearch }: SettingsHeaderProps) => {
|
||||
</LibraryHeaderBar.Title>
|
||||
</Group>
|
||||
<Group>
|
||||
{showUpdateAvailable && <UpdateAvailableButton />}
|
||||
<SearchInput
|
||||
defaultValue={search}
|
||||
onChange={(event) => setSearch(event.target.value.toLocaleLowerCase())}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const SettingsContextModal = () => {
|
||||
|
||||
return (
|
||||
<SettingSearchContext.Provider value={search}>
|
||||
<SettingsHeader setSearch={setSearch} />
|
||||
<SettingsHeader setSearch={setSearch} showUpdateAvailable />
|
||||
<SettingsContent />
|
||||
</SettingSearchContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { toTag } from '/@/renderer/hooks';
|
||||
import { useLatestVersion } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
|
||||
export const UpdateAvailableButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentVersion, isUpdateAvailable, latestVersion } = useLatestVersion();
|
||||
|
||||
if (!isUpdateAvailable || !latestVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://github.com/jeffvli/feishin/releases/tag/${toTag(latestVersion || currentVersion)}`}
|
||||
size="compact-sm"
|
||||
target="_blank"
|
||||
variant="filled"
|
||||
>
|
||||
{t('common.newVersionAvailable')}: v{latestVersion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -28,6 +28,17 @@ export const sharedQueries = {
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
scanStatus: (args: QueryHookArgs<null>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getScanStatus({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.server.scanStatus(args.serverId),
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
tagList: (args: QueryHookArgs<TagListQuery>) => {
|
||||
return queryOptions({
|
||||
gcTime: 1000 * 60 * 24,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useSyncExternalStore } from 'react';
|
||||
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import { useCurrentServerId } from '/@/renderer/store';
|
||||
|
||||
const SCAN_STATUS_POLL_INTERVAL_MS = 2000;
|
||||
const SCAN_WATCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
let isWatchingScan = false;
|
||||
let hasSeenScanStart = false;
|
||||
let watchTimeoutId: null | ReturnType<typeof setTimeout> = null;
|
||||
const watchListeners = new Set<() => void>();
|
||||
|
||||
const subscribeWatchingScan = (listener: () => void) => {
|
||||
watchListeners.add(listener);
|
||||
return () => {
|
||||
watchListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
const getIsWatchingScan = () => isWatchingScan;
|
||||
|
||||
const setIsWatchingScan = (value: boolean) => {
|
||||
if (isWatchingScan === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isWatchingScan = value;
|
||||
for (const listener of watchListeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const clearWatchTimeout = () => {
|
||||
if (watchTimeoutId !== null) {
|
||||
clearTimeout(watchTimeoutId);
|
||||
watchTimeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const stopWatchingScan = () => {
|
||||
clearWatchTimeout();
|
||||
hasSeenScanStart = false;
|
||||
setIsWatchingScan(false);
|
||||
};
|
||||
|
||||
const startScanWatch = () => {
|
||||
clearWatchTimeout();
|
||||
hasSeenScanStart = false;
|
||||
setIsWatchingScan(true);
|
||||
watchTimeoutId = setTimeout(() => {
|
||||
watchTimeoutId = null;
|
||||
if (isWatchingScan && !hasSeenScanStart) {
|
||||
stopWatchingScan();
|
||||
}
|
||||
}, SCAN_WATCH_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const invalidateLibraryQueriesAfterScan = (queryClient: QueryClient, serverId: string) => {
|
||||
return Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.songs.root(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.albums.root(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.albumArtists.root(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.artists.root(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.genres.root(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: [serverId, 'folders'] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.musicFolders.list(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.search.root(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: [serverId, 'tags'] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.roles.list(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.playlists.root(serverId) }),
|
||||
queryClient.invalidateQueries({ queryKey: [serverId, 'home'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['home'] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const finishScanWatch = (queryClient: QueryClient, serverId: string) => {
|
||||
if (!hasSeenScanStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearWatchTimeout();
|
||||
hasSeenScanStart = false;
|
||||
setIsWatchingScan(false);
|
||||
invalidateLibraryQueriesAfterScan(queryClient, serverId);
|
||||
};
|
||||
|
||||
eventEmitter.on('TAG_EDITED', () => {
|
||||
startScanWatch();
|
||||
});
|
||||
|
||||
export const useScanStatus = () => {
|
||||
const serverId = useCurrentServerId();
|
||||
const queryClient = useQueryClient();
|
||||
const isWatching = useSyncExternalStore(subscribeWatchingScan, getIsWatchingScan);
|
||||
|
||||
const query = useQuery({
|
||||
...sharedQueries.scanStatus({
|
||||
options: {
|
||||
enabled: isWatching && Boolean(serverId),
|
||||
refetchInterval: SCAN_STATUS_POLL_INTERVAL_MS,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
throwOnError: false,
|
||||
},
|
||||
query: null,
|
||||
serverId,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWatching || !query.isFetched || query.isFetching || !query.data || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.data.scanning) {
|
||||
hasSeenScanStart = true;
|
||||
clearWatchTimeout();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep polling until the scan has actually started, then stop when it finishes.
|
||||
if (hasSeenScanStart && !query.data.scanning) {
|
||||
finishScanWatch(queryClient, serverId);
|
||||
}
|
||||
}, [isWatching, query.data, query.isFetched, query.isFetching, queryClient, serverId]);
|
||||
|
||||
return {
|
||||
...query,
|
||||
isScanning: query.data?.scanning === true,
|
||||
isWatching,
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
|
||||
|
||||
import styles from './action-bar.module.css';
|
||||
|
||||
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import { useCommandPalette } from '/@/renderer/store';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
@@ -15,6 +16,7 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
export const ActionBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { open } = useCommandPalette();
|
||||
const { isScanning } = useScanStatus();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -49,7 +51,11 @@ export const ActionBar = () => {
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button p="0">
|
||||
<Icon icon="menu" size="lg" />
|
||||
<Icon
|
||||
animate={isScanning ? 'spin' : undefined}
|
||||
icon={isScanning ? 'spinner' : 'menu'}
|
||||
size="lg"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link, NavLink, useNavigate } from 'react-router';
|
||||
|
||||
import styles from './collapsed-sidebar.module.css';
|
||||
|
||||
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
|
||||
import { CollapsedSidebarButton } from '/@/renderer/features/sidebar/components/collapsed-sidebar-button';
|
||||
import { CollapsedSidebarItem } from '/@/renderer/features/sidebar/components/collapsed-sidebar-item';
|
||||
import { getCollectionTo } from '/@/renderer/features/sidebar/components/sidebar-collection-list';
|
||||
@@ -35,6 +36,8 @@ export const CollapsedSidebar = () => {
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const sidebarCollapsedNavigation = useSidebarCollapsedNavigation();
|
||||
const sidebarItems = useSidebarItems();
|
||||
const { isScanning } = useScanStatus();
|
||||
|
||||
const translatedSidebarItemMap = useMemo(
|
||||
() => ({
|
||||
Albums: t('page.sidebar.albums'),
|
||||
@@ -94,7 +97,14 @@ export const CollapsedSidebar = () => {
|
||||
<CollapsedSidebarItem
|
||||
activeIcon={null}
|
||||
component={Flex}
|
||||
icon={<Icon fill="muted" icon="menu" size="3xl" />}
|
||||
icon={
|
||||
<Icon
|
||||
animate={isScanning ? 'spin' : undefined}
|
||||
fill="muted"
|
||||
icon={isScanning ? 'spinner' : 'menu'}
|
||||
size="3xl"
|
||||
/>
|
||||
}
|
||||
label={t('common.menu')}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
|
||||
@@ -7,6 +7,7 @@ import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png';
|
||||
import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png';
|
||||
import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png';
|
||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import { useScanStatus } from '/@/renderer/features/shared/hooks/use-scan-status';
|
||||
import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
@@ -23,6 +24,7 @@ import { ServerFeature } from '/@/shared/types/features-types';
|
||||
export const ServerSelector = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentServer = useCurrentServer();
|
||||
const { data: scanStatus, isScanning, isWatching } = useScanStatus();
|
||||
|
||||
const { data: musicFolders } = useQuery(
|
||||
currentServer
|
||||
@@ -54,6 +56,19 @@ export const ServerSelector = () => {
|
||||
return selectedMusicFolders[0].name;
|
||||
})();
|
||||
|
||||
const scanProgressParts: string[] = [];
|
||||
if (scanStatus?.count && scanStatus.count > 0) {
|
||||
scanProgressParts.push(t('common.scanItemCount', { count: scanStatus.count }));
|
||||
}
|
||||
if (scanStatus?.folderCount && scanStatus.folderCount > 0) {
|
||||
scanProgressParts.push(t('common.scanFolderCount', { count: scanStatus.folderCount }));
|
||||
}
|
||||
|
||||
const scanStatusText =
|
||||
isWatching && isScanning
|
||||
? [t('common.scanningLibrary'), ...scanProgressParts].filter(Boolean).join(' · ')
|
||||
: null;
|
||||
|
||||
const logo =
|
||||
currentServer.type === ServerType.NAVIDROME
|
||||
? NavidromeLogo
|
||||
@@ -75,6 +90,11 @@ export const ServerSelector = () => {
|
||||
<Text isMuted size="xs" truncate>
|
||||
{musicFolderDisplayText}
|
||||
</Text>
|
||||
{scanStatusText && (
|
||||
<Text isMuted size="xs" truncate>
|
||||
{scanStatusText}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Icon icon="ellipsisVertical" size="sm" />
|
||||
</Group>
|
||||
|
||||
@@ -15,10 +15,11 @@ import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../uti
|
||||
import { base64ToBytes, bytesToBase64, formatBatchFileErrors } from '../utils/utils';
|
||||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { eventEmitter } from '/@/renderer/events/event-emitter';
|
||||
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export const EDIT_SCOPE_ALL = '__all__';
|
||||
|
||||
@@ -493,6 +494,14 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
||||
}
|
||||
}
|
||||
|
||||
if (server) {
|
||||
eventEmitter.emit('TAG_EDITED', {
|
||||
id: songsInScope.map((s) => s.id),
|
||||
itemType: LibraryItem.SONG,
|
||||
serverId: server.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (close) {
|
||||
closeAllModals();
|
||||
return;
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Fragment, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import packageJson from '../../../../../package.json';
|
||||
import styles from './app-menu.module.css';
|
||||
|
||||
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
|
||||
import { ServerList } from '/@/renderer/features/servers/components/server-list';
|
||||
import { UpdateAvailableButton } from '/@/renderer/features/settings/components/update-available-button';
|
||||
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
|
||||
import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector';
|
||||
import { openReleaseNotesModal } from '/@/renderer/release-notes-modal';
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
useCommandPalette,
|
||||
useCurrentServer,
|
||||
useGeneralSettings,
|
||||
useLatestVersion,
|
||||
useSettingsStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { DropdownMenu, MenuItemProps } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
@@ -138,6 +140,8 @@ export const AppMenu = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const { currentVersion } = useLatestVersion();
|
||||
|
||||
const serverHeaderMenuItems: MenuItem[] = currentServer
|
||||
? [
|
||||
{
|
||||
@@ -270,10 +274,10 @@ export const AppMenu = () => {
|
||||
{
|
||||
icon: 'brandGitHub',
|
||||
id: 'version',
|
||||
label: t('page.appMenu.version', { version: packageJson.version }),
|
||||
label: t('page.appMenu.version', { version: currentVersion }),
|
||||
onClick: () =>
|
||||
openReleaseNotesModal(
|
||||
t('common.newVersion', { version: packageJson.version }) as string,
|
||||
t('common.newVersion', { version: currentVersion }) as string,
|
||||
),
|
||||
type: 'item',
|
||||
},
|
||||
@@ -301,6 +305,15 @@ export const AppMenu = () => {
|
||||
},
|
||||
type: 'conditional-item',
|
||||
},
|
||||
{
|
||||
component: (
|
||||
<Flex align="center" justify="center" w="100%">
|
||||
<UpdateAvailableButton />
|
||||
</Flex>
|
||||
),
|
||||
id: 'update-available',
|
||||
type: 'custom',
|
||||
},
|
||||
{
|
||||
id: 'divider-5',
|
||||
type: 'divider',
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './use-app-focus';
|
||||
export * from './use-check-for-updates';
|
||||
export * from './use-container-query';
|
||||
export * from './use-fast-average-color';
|
||||
export * from './use-github-releases';
|
||||
export * from './use-hide-scrollbar';
|
||||
export * from './use-hotkeys';
|
||||
export * from './use-is-mounted';
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import isElectron from 'is-electron';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { parseVersionFromTag, useGithubLatestRelease } from '/@/renderer/hooks/use-github-releases';
|
||||
import { useAppStore } from '/@/renderer/store';
|
||||
|
||||
// 6 hours
|
||||
const CHECK_FOR_UPDATES_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
const utils = isElectron() ? window.api?.utils : null;
|
||||
|
||||
export const useCheckForUpdates = () => {
|
||||
const [enablePeriodicCheck, setEnablePeriodicCheck] = useState(false);
|
||||
const setLatestVersion = useAppStore((state) => state.actions.setLatestVersion);
|
||||
|
||||
// We want to skip the first check since it's already checked in the main process when the app is started
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setEnablePeriodicCheck(true), CHECK_FOR_UPDATES_INTERVAL_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const isEnabled =
|
||||
enablePeriodicCheck &&
|
||||
Boolean(isElectron() && utils?.checkForUpdates && !utils?.disableAutoUpdates?.());
|
||||
|
||||
return useQuery({
|
||||
enabled: isEnabled,
|
||||
queryFn: () => utils?.checkForUpdates?.(),
|
||||
queryKey: ['app-check-for-updates'],
|
||||
const query = useGithubLatestRelease({
|
||||
refetchInterval: CHECK_FOR_UPDATES_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setLatestVersion(parseVersionFromTag(query.data.tag_name));
|
||||
}
|
||||
}, [query.data, setLatestVersion]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
|
||||
export const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases';
|
||||
export const RELEASES_TO_FETCH = 30;
|
||||
|
||||
export interface GitHubRelease {
|
||||
body: null | string;
|
||||
name: null | string;
|
||||
prerelease: boolean;
|
||||
published_at: string;
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
export function parseVersionFromTag(tagName: string): string {
|
||||
return tagName.startsWith('v') ? tagName.slice(1) : tagName;
|
||||
}
|
||||
|
||||
export function toTag(version: string): string {
|
||||
return version.startsWith('v') ? version : `v${version}`;
|
||||
}
|
||||
|
||||
export const useGithubReleasesList = (perPage = RELEASES_TO_FETCH) => {
|
||||
return useQuery({
|
||||
queryFn: async () => {
|
||||
const response = await axios.get<GitHubRelease[]>(GITHUB_RELEASES_URL, {
|
||||
params: { per_page: perPage },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
queryKey: ['github-releases-list', perPage],
|
||||
retry: 2,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGithubLatestRelease = (options?: {
|
||||
refetchInterval?: number;
|
||||
refetchIntervalInBackground?: boolean;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryFn: async () => {
|
||||
const response = await axios.get<GitHubRelease>(`${GITHUB_RELEASES_URL}/latest`);
|
||||
return response.data;
|
||||
},
|
||||
queryKey: ['github-latest-release'],
|
||||
retry: 2,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -7,6 +7,14 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
import {
|
||||
GITHUB_RELEASES_URL,
|
||||
type GitHubRelease,
|
||||
parseVersionFromTag,
|
||||
RELEASES_TO_FETCH,
|
||||
toTag,
|
||||
useGithubReleasesList,
|
||||
} from '/@/renderer/hooks/use-github-releases';
|
||||
import { formatHrDateTime } from '/@/renderer/utils/format';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
@@ -19,9 +27,7 @@ import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
|
||||
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jeffvli/feishin/releases';
|
||||
const GITHUB_COMPARE_URL = 'https://api.github.com/repos/jeffvli/feishin/compare';
|
||||
const RELEASES_TO_FETCH = 30;
|
||||
|
||||
interface GitHubCompareCommit {
|
||||
commit: {
|
||||
@@ -37,14 +43,6 @@ interface GitHubCompareResponse {
|
||||
total_commits: number;
|
||||
}
|
||||
|
||||
interface GitHubRelease {
|
||||
body: null | string;
|
||||
name: null | string;
|
||||
prerelease: boolean;
|
||||
published_at: string;
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
interface ReleaseNotesContentProps {
|
||||
onDismiss: () => void;
|
||||
version: string;
|
||||
@@ -54,30 +52,13 @@ function isAlphaVersion(version: string): boolean {
|
||||
return version.includes('-alpha');
|
||||
}
|
||||
|
||||
function parseVersionFromTag(tagName: string): string {
|
||||
return tagName.startsWith('v') ? tagName.slice(1) : tagName;
|
||||
}
|
||||
|
||||
function toTag(version: string): string {
|
||||
return version.startsWith('v') ? version : `v${version}`;
|
||||
}
|
||||
|
||||
const ReleaseNotesContent = ({ onDismiss, version }: ReleaseNotesContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedVersion, setSelectedVersion] = useState(version);
|
||||
const isAlpha = isAlphaVersion(selectedVersion);
|
||||
|
||||
// Fetch list of recent releases for the selector
|
||||
const { data: releasesList = [] } = useQuery({
|
||||
queryFn: async () => {
|
||||
const response = await axios.get<GitHubRelease[]>(GITHUB_RELEASES_URL, {
|
||||
params: { per_page: RELEASES_TO_FETCH },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
queryKey: ['github-releases-list'],
|
||||
retry: 2,
|
||||
});
|
||||
const { data: releasesList = [] } = useGithubReleasesList();
|
||||
|
||||
const latestStableRelease = useMemo(() => {
|
||||
return releasesList.find((r) => !r.prerelease);
|
||||
|
||||
@@ -2,11 +2,15 @@ import type { ItemListStateItem } from '/@/renderer/components/item-list/helpers
|
||||
import type { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
import merge from 'lodash/merge';
|
||||
import semverGt from 'semver/functions/gt';
|
||||
import semverValid from 'semver/functions/valid';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
import { AlbumListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
@@ -24,6 +28,7 @@ export interface AppSlice extends AppState {
|
||||
setGenreIdsMode: (mode: 'and' | 'or') => void;
|
||||
setGenreSelectMode: (mode: 'multi' | 'single') => void;
|
||||
setGlobalExpanded: (value: GlobalExpandedState | null) => void;
|
||||
setLatestVersion: (version: null | string) => void;
|
||||
setPageSidebar: (key: string, value: boolean) => void;
|
||||
setPrivateMode: (enabled: boolean) => void;
|
||||
setShowTimeRemaining: (enabled: boolean) => void;
|
||||
@@ -52,6 +57,7 @@ export interface AppState {
|
||||
genreSelectMode: 'multi' | 'single';
|
||||
globalExpanded: GlobalExpandedState | null;
|
||||
isReorderingQueue: boolean;
|
||||
latestVersion: null | string;
|
||||
pageSidebar: Record<string, boolean>;
|
||||
platform: Platform;
|
||||
privateMode: boolean;
|
||||
@@ -157,6 +163,11 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
||||
state.globalExpanded = value;
|
||||
});
|
||||
},
|
||||
setLatestVersion: (version) => {
|
||||
set((state) => {
|
||||
state.latestVersion = version;
|
||||
});
|
||||
},
|
||||
setPageSidebar: (key, value) => {
|
||||
set((state) => {
|
||||
state.pageSidebar[key] = value;
|
||||
@@ -219,6 +230,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
||||
genreSelectMode: 'multi',
|
||||
globalExpanded: null,
|
||||
isReorderingQueue: false,
|
||||
latestVersion: null,
|
||||
pageSidebar: {
|
||||
album: true,
|
||||
song: true,
|
||||
@@ -261,7 +273,7 @@ export const useAppStore = createWithEqualityFn<AppSlice>()(
|
||||
name: 'store_app',
|
||||
partialize: (state) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignore non-persisted state
|
||||
const { globalExpanded: _, ...rest } = state;
|
||||
const { globalExpanded: _, latestVersion: __, ...rest } = state;
|
||||
return rest;
|
||||
},
|
||||
version: 5,
|
||||
@@ -307,6 +319,18 @@ export const useGlobalExpanded = () => useAppStore((state) => state.globalExpand
|
||||
|
||||
export const useSetGlobalExpanded = () => useAppStore((state) => state.actions.setGlobalExpanded);
|
||||
|
||||
export const useLatestVersion = () => {
|
||||
const latestVersion = useAppStore((state) => state.latestVersion);
|
||||
const currentVersion = packageJson.version;
|
||||
const isUpdateAvailable =
|
||||
!!latestVersion &&
|
||||
!!semverValid(latestVersion) &&
|
||||
!!semverValid(currentVersion) &&
|
||||
semverGt(latestVersion, currentVersion);
|
||||
|
||||
return { currentVersion, isUpdateAvailable, latestVersion };
|
||||
};
|
||||
|
||||
export const useGlobalExpandedState = () => {
|
||||
const globalExpanded = useGlobalExpanded();
|
||||
const setGlobalExpanded = useSetGlobalExpanded();
|
||||
|
||||
@@ -1055,6 +1055,8 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
? state.queue.shuffled.length
|
||||
: queue.items.length;
|
||||
|
||||
const isStopped = state.player.status === PlayerStatus.STOPPED;
|
||||
|
||||
if (repeat === PlayerRepeat.ONE) {
|
||||
// Manual next while repeat-one is active should still advance in the queue.
|
||||
const nextIndex = Math.min(playbackLength - 1, currentIndex + 1);
|
||||
@@ -1063,6 +1065,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
state.player.index = nextIndex;
|
||||
state.player.playerNum = 1;
|
||||
setTimestampStore(0);
|
||||
|
||||
if (isStopped) {
|
||||
state.player.status = PlayerStatus.PLAYING;
|
||||
}
|
||||
});
|
||||
|
||||
eventEmitter.emit('MEDIA_NEXT', {
|
||||
@@ -1112,6 +1118,10 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
state.player.index = nextIndex;
|
||||
state.player.playerNum = 1;
|
||||
setTimestampStore(0);
|
||||
|
||||
if (isStopped) {
|
||||
state.player.status = PlayerStatus.PLAYING;
|
||||
}
|
||||
});
|
||||
|
||||
eventEmitter.emit('MEDIA_NEXT', {
|
||||
@@ -1246,10 +1256,17 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
previousIndex = Math.max(0, currentIndex - 1);
|
||||
}
|
||||
|
||||
// Same Chromium Media Session pitfall as mediaNext: a STOPPED→new-src
|
||||
// transition without PLAYING drops OS media-key routing.
|
||||
const resumeFromStopped = get().player.status === PlayerStatus.STOPPED;
|
||||
|
||||
set((state) => {
|
||||
state.player.index = previousIndex;
|
||||
state.player.playerNum = 1;
|
||||
setTimestampStore(0);
|
||||
if (resumeFromStopped) {
|
||||
state.player.status = PlayerStatus.PLAYING;
|
||||
}
|
||||
});
|
||||
|
||||
eventEmitter.emit('MEDIA_PREV', {
|
||||
|
||||
@@ -2052,7 +2052,7 @@ const initialState: SettingsState = {
|
||||
ALBUMARTISTSSORT: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
multiValue: false,
|
||||
},
|
||||
artist: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
@@ -2067,12 +2067,12 @@ const initialState: SettingsState = {
|
||||
artistSort: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
multiValue: false,
|
||||
},
|
||||
ARTISTSSORT: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
multiValue: false,
|
||||
},
|
||||
genre: {
|
||||
autocompleteSource: 'serverGenres',
|
||||
@@ -2705,10 +2705,26 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 32) {
|
||||
const tagConfigs = state.tagEditor?.tagConfigs;
|
||||
if (tagConfigs) {
|
||||
for (const key of [
|
||||
'albumArtistSort',
|
||||
'ALBUMARTISTSSORT',
|
||||
'artistSort',
|
||||
'ARTISTSSORT',
|
||||
] as const) {
|
||||
if (tagConfigs[key]) {
|
||||
tagConfigs[key].multiValue = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return persistedState;
|
||||
},
|
||||
name: 'store_settings',
|
||||
version: 31,
|
||||
version: 32,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -760,6 +760,40 @@ const serverInfo = z.object({
|
||||
Version: z.string(),
|
||||
});
|
||||
|
||||
const taskTriggerInfo = z.object({
|
||||
DayOfWeek: z.string().nullish(),
|
||||
IntervalTicks: z.number().nullish(),
|
||||
MaxRuntimeTicks: z.number().nullish(),
|
||||
TimeOfDayTicks: z.number().nullish(),
|
||||
Type: z.string().nullish(),
|
||||
});
|
||||
|
||||
const taskResult = z.object({
|
||||
EndTimeUtc: z.string().nullish(),
|
||||
ErrorMessage: z.string().nullish(),
|
||||
Id: z.string().nullish(),
|
||||
Key: z.string().nullish(),
|
||||
LongErrorMessage: z.string().nullish(),
|
||||
Name: z.string().nullish(),
|
||||
StartTimeUtc: z.string().nullish(),
|
||||
Status: z.string().nullish(),
|
||||
});
|
||||
|
||||
const taskInfo = z.object({
|
||||
Category: z.string().nullish(),
|
||||
CurrentProgressPercentage: z.number().nullish(),
|
||||
Description: z.string().nullish(),
|
||||
Id: z.string().nullish(),
|
||||
IsHidden: z.boolean().optional(),
|
||||
Key: z.string().nullish(),
|
||||
LastExecutionResult: taskResult.nullish(),
|
||||
Name: z.string().nullish(),
|
||||
State: z.enum(['Idle', 'Cancelling', 'Running']).optional(),
|
||||
Triggers: z.array(taskTriggerInfo).nullish(),
|
||||
});
|
||||
|
||||
const scheduledTasks = z.array(taskInfo);
|
||||
|
||||
const similarSongsParameters = z.object({
|
||||
Fields: z.array(z.string()).readonly().optional(),
|
||||
Limit: z.number().optional(),
|
||||
@@ -914,6 +948,7 @@ export const jfType = {
|
||||
playlistList,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
scheduledTasks,
|
||||
scrobble,
|
||||
search,
|
||||
serverInfo,
|
||||
|
||||
@@ -132,7 +132,7 @@ img.size-5xl {
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
|
||||
@@ -1332,6 +1332,10 @@ export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyr
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type GetScanStatusArgs = BaseEndpointArgs;
|
||||
|
||||
export type GetScanStatusResponse = ScanStatus;
|
||||
|
||||
export type InternetProviderLyricResponse = {
|
||||
artist: string;
|
||||
id: string;
|
||||
@@ -1388,6 +1392,13 @@ export type RefreshItemsArgs = BaseEndpointArgs & { query: { ids: string[] } };
|
||||
|
||||
export type RefreshItemsResponse = null;
|
||||
|
||||
export type ScanStatus = {
|
||||
count: number;
|
||||
folderCount: number;
|
||||
lastScan?: string;
|
||||
scanning: boolean;
|
||||
};
|
||||
|
||||
export type ScrobbleArgs = BaseEndpointArgs & {
|
||||
query: ScrobbleQuery;
|
||||
};
|
||||
@@ -1568,6 +1579,7 @@ export type ControllerEndpoint = {
|
||||
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
|
||||
getScanStatus: (args: GetScanStatusArgs) => Promise<GetScanStatusResponse>;
|
||||
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
||||
getSimilarSongs: (args: SimilarSongsArgs) => Promise<Song[]>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
@@ -1733,6 +1745,9 @@ export type InternalControllerEndpoint = {
|
||||
getRoles: (
|
||||
args: ReplaceApiClientProps<BaseEndpointArgs>,
|
||||
) => Promise<Array<string | { label: string; value: string }>>;
|
||||
getScanStatus: (
|
||||
args: ReplaceApiClientProps<GetScanStatusArgs>,
|
||||
) => Promise<GetScanStatusResponse>;
|
||||
getServerInfo: (args: ReplaceApiClientProps<ServerInfoArgs>) => Promise<ServerInfo>;
|
||||
getSimilarSongs: (args: ReplaceApiClientProps<SimilarSongsArgs>) => Promise<Song[]>;
|
||||
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
|
||||
|
||||
Reference in New Issue
Block a user