Compare commits

...

14 Commits

Author SHA1 Message Date
jeffvli f02307ff2a move search button to top right of LibraryHeader 2026-02-11 21:32:43 -08:00
jeffvli 2647c36326 add compact styling to LibraryHeader 2026-02-11 21:31:17 -08:00
jeffvli 16a9d6e702 update client side song ordering to include album order 2026-02-11 20:01:00 -08:00
jeffvli 0a4d789f08 maintain song order in album view 2026-02-11 20:00:46 -08:00
jeffvli 7f5742119b refactor playlist route state 2026-02-11 18:43:28 -08:00
jeffvli 04d8e013e1 add initial playlist album view 2026-02-11 14:22:47 -08:00
jeffvli 022b83ab32 fix playlist add returning zero results on modal menu (#1695) 2026-02-11 00:35:22 -08:00
jeffvli 551d705ee1 adjust fixed-width columns on the Item Detail list and prevent text wrapping 2026-02-10 21:52:22 -08:00
jeffvli 83f73c7fa9 remove unused enableAnimation from ImageContainer 2026-02-10 21:46:54 -08:00
York cc8cb4f4f1 Add sleep timer to player bar (#1671)
* feat: add sleep timer to player bar

- Add sleep timer button in player bar right controls
- Preset options: End of song, 5/10/15/30/45 min, 1 hr, 2 hrs
- Custom timer with HH:MM:SS input fields
- Timer only counts down while music is playing
- Timer pauses playback when it expires
- End-of-song mode pauses at the next track change
- Uses theme-aware styling (--theme-colors-surface)
- Add sleepTimer/sleepTimerOff icons (LuTimer/LuTimerOff)
- Add i18n strings for sleep timer UI

---------

Co-authored-by: York <york@BonecharMac.local>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2026-02-10 21:19:37 -08:00
York 496eab7d09 fix: regenerate macOS icon (.icns) to fix glitched small icons (#1688)
Co-authored-by: York <york@BonecharMac.local>
2026-02-10 21:11:10 -08:00
York 5197c967c2 fix: use theme mode property for macOS native window theme (#1685)
Co-authored-by: York <york@BonecharMac.local>
2026-02-10 21:09:32 -08:00
jeffvli 74b615dba7 include stable version check on alpha update 2026-02-10 20:20:37 -08:00
jeffvli b67ee797cb move arm64 build configuration to electron-builder config (#1689) 2026-02-10 19:25:26 -08:00
47 changed files with 1493 additions and 205 deletions
-11
View File
@@ -155,17 +155,6 @@ jobs:
pnpm run publish:win:alpha
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (Windows ARM64)
if: matrix.os == 'windows-latest'
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64:alpha
on_retry_command: pnpm cache delete
- name: Build and Publish to R2 (macOS)
if: matrix.os == 'macos-latest'
uses: nick-invision/retry@v2.8.2
-13
View File
@@ -155,19 +155,6 @@ jobs:
pnpm run publish:win:beta
on_retry_command: pnpm cache delete
- name: Build and Publish releases (Windows ARM64)
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64:beta
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest'
env:
-10
View File
@@ -50,16 +50,6 @@ jobs:
command: |
pnpm run package:win:pr
- name: Build for Windows (ARM64)
if: ${{ matrix.os == 'windows-latest' }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run package:win-arm64:pr
- name: Build for Linux
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: nick-invision/retry@v2.8.2
-12
View File
@@ -33,15 +33,3 @@ jobs:
command: |
pnpm run publish:win
on_retry_command: pnpm cache delete
- name: Build and Publish releases (ARM64)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64
on_retry_command: pnpm cache delete
-13
View File
@@ -35,19 +35,6 @@ jobs:
pnpm run publish:win
on_retry_command: pnpm cache delete
- name: Build and Publish releases (Windows ARM64)
if: matrix.os == 'windows-latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
pnpm run publish:win-arm64
on_retry_command: pnpm cache delete
- name: Build and Publish releases (macOS)
if: matrix.os == 'macos-latest'
env:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 B

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.
+9 -3
View File
@@ -13,9 +13,15 @@ asarUnpack:
- resources/**
win:
target:
- zip
- nsis
icon: assets/icons/icon.png
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
nsis:
allowToChangeInstallationDirectory: true
+9 -3
View File
@@ -13,9 +13,15 @@ asarUnpack:
- resources/**
win:
target:
- zip
- nsis
icon: assets/icons/icon.png
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
nsis:
allowToChangeInstallationDirectory: true
+8 -2
View File
@@ -13,8 +13,14 @@ asarUnpack:
- resources/**
win:
target:
- zip
- nsis
- target: zip
arch:
- x64
- arm64
- target: nsis
arch:
- x64
- arm64
icon: assets/icons/icon.ico
nsis:
Regular → Executable
+10 -1
View File
@@ -667,7 +667,16 @@
"trackRadio": "track radio",
"unfavorite": "unfavorite",
"pause": "pause",
"viewQueue": "view queue"
"viewQueue": "view queue",
"sleepTimer": "sleep timer",
"sleepTimer_endOfSong": "end of current song",
"sleepTimer_minutes": "{{count}} min",
"sleepTimer_hours": "{{count}} hr",
"sleepTimer_custom": "custom",
"sleepTimer_off": "off",
"sleepTimer_timeRemaining": "{{time}} remaining",
"sleepTimer_setCustom": "set timer",
"sleepTimer_cancel": "cancel timer"
},
"queryBuilder": {
"standardTags": "standard tags",
+108 -19
View File
@@ -1,3 +1,5 @@
import type { UpdateCheckResult } from 'electron-updater';
import { is } from '@electron-toolkit/utils';
import {
app,
@@ -21,6 +23,7 @@ import log from 'electron-log/main';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import { access, constants } from 'fs';
import path, { join } from 'path';
import semver from 'semver';
import packageJson from '../../package.json';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
@@ -54,27 +57,17 @@ const ALPHA_UPDATER_CONFIG: {
type UpdaterInstance = AppImageUpdater | MacUpdater | NsisUpdater | typeof autoUpdater;
class AlphaAppUpdater {
constructor() {
const updater = createAlphaUpdaterInstance();
log.transports.file.level = 'info';
updater.logger = autoUpdaterLogInterface;
updater.channel = ALPHA_UPDATER_CONFIG.channel;
updater.allowPrerelease = true;
updater.disableDifferentialDownload = true;
updater.allowDowngrade = true;
updater.autoInstallOnAppQuit = true;
updater.autoRunAppAfterInstall = true;
updater.checkForUpdatesAndNotify();
}
}
class AppUpdater {
constructor() {
const effectiveChannel = store.get('release_channel') as string;
console.log('Effective update channel:', effectiveChannel);
if (effectiveChannel === 'alpha') {
return new AlphaAppUpdater();
checkAllChannelsAndGetBest().then(({ updater: updaterInstance }) => {
updaterInstance.autoInstallOnAppQuit = true;
updaterInstance.autoRunAppAfterInstall = true;
updaterInstance.checkForUpdatesAndNotify();
});
return;
}
configureAndGetUpdater();
@@ -82,6 +75,71 @@ class AppUpdater {
}
}
// When release channel is alpha, check alpha and latest for updates and return
// the updater + result for the newest version found (so alpha users can receive
// latest updates when they are newer than the current alpha).
async function checkAllChannelsAndGetBest(): Promise<{
result: null | UpdateCheckResult;
updater: UpdaterInstance;
}> {
const currentVersion = packageJson.version;
const candidates: Array<{
channel: 'alpha' | 'beta' | 'latest';
result: UpdateCheckResult;
updater: UpdaterInstance;
}> = [];
const alphaUpdater = createAlphaUpdaterInstance();
alphaUpdater.logger = autoUpdaterLogInterface;
alphaUpdater.channel = ALPHA_UPDATER_CONFIG.channel;
alphaUpdater.allowPrerelease = true;
alphaUpdater.disableDifferentialDownload = true;
alphaUpdater.allowDowngrade = true;
try {
const alphaResult = await alphaUpdater.checkForUpdates();
if (
alphaResult?.updateInfo?.version &&
alphaResult.isUpdateAvailable &&
semver.valid(alphaResult.updateInfo.version) &&
semver.gt(alphaResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'alpha', result: alphaResult, updater: alphaUpdater });
}
} catch (e) {
log.warn('Alpha channel check failed', e);
}
try {
configureAutoUpdaterForChannel('latest');
const latestResult = await autoUpdater.checkForUpdates();
if (
latestResult?.updateInfo?.version &&
latestResult.isUpdateAvailable &&
semver.valid(latestResult.updateInfo.version) &&
semver.gt(latestResult.updateInfo.version, currentVersion)
) {
candidates.push({ channel: 'latest', result: latestResult, updater: autoUpdater });
}
} catch (e) {
log.warn('Latest channel check failed', e);
}
if (candidates.length === 0) {
return { result: null, updater: alphaUpdater };
}
const best = candidates.reduce((a, b) =>
semver.gt(a.result.updateInfo.version, b.result.updateInfo.version) ? a : b,
);
if (best.channel === 'latest') {
configureAutoUpdaterForChannel('latest');
}
return { result: best.result, updater: best.updater };
}
function configureAndGetUpdater(): UpdaterInstance {
const isBetaVersion = packageJson.version.includes('-beta');
const isAlphaVersion = packageJson.version.includes('-alpha');
@@ -122,17 +180,37 @@ function configureAndGetUpdater(): UpdaterInstance {
if (effectiveChannel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = false;
}
return autoUpdater;
}
/**
* Configures the global autoUpdater for a specific GitHub channel (beta or latest).
* Used when checking multiple channels or when the winning channel is beta/latest.
*/
function configureAutoUpdaterForChannel(channel: 'beta' | 'latest'): void {
log.transports.file.level = 'info';
autoUpdater.logger = autoUpdaterLogInterface;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.autoRunAppAfterInstall = true;
if (channel === 'beta') {
autoUpdater.channel = 'beta';
autoUpdater.allowDowngrade = true;
autoUpdater.allowPrerelease = true;
autoUpdater.disableDifferentialDownload = true;
} else {
autoUpdater.channel = 'latest';
autoUpdater.allowPrerelease = false;
}
}
function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdater {
if (isMacOS()) {
return new MacUpdater(ALPHA_UPDATER_CONFIG);
@@ -440,8 +518,19 @@ async function createWindow(first = true): Promise<void> {
try {
console.log('Checking for updates');
const updater = configureAndGetUpdater();
const result = await updater.checkForUpdates();
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) {
@@ -38,6 +38,7 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
[AlbumListSort.DURATION]: undefined,
[AlbumListSort.EXPLICIT_STATUS]: undefined,
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
[AlbumListSort.ID]: undefined,
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
@@ -244,8 +244,6 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs
const playType = (meta?.playType as Play) || Play.NOW;
const singleSongOnly = meta?.singleSongOnly === true;
// For single-song actions (e.g. image play button), or NEXT/LAST/..., only add the clicked song
// For row double-click with NOW/SHUFFLE, add a range of songs around the clicked song
let songsToAdd: Song[];
if (
singleSongOnly ||
@@ -56,6 +56,7 @@
.tracks-table-header {
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
align-items: center;
width: 100%;
@@ -80,12 +81,14 @@
.track-header-cell {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
min-height: 60%;
padding-right: var(--theme-spacing-sm);
padding-left: var(--theme-spacing-sm);
overflow: visible;
white-space: nowrap;
}
.track-header-cell-no-h-padding {
@@ -84,6 +84,7 @@ interface ItemDetailListProps {
internalState?: ItemListStateActions;
itemCount?: number;
items?: unknown[];
listKey?: ItemListKey;
onColumnReordered?: (
columnIdFrom: TableColumn,
columnIdTo: TableColumn,
@@ -92,8 +93,15 @@ interface ItemDetailListProps {
onColumnResized?: (columnId: TableColumn, width: number) => void;
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void;
onScrollEnd?: (rowIndex: number) => void;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
overrideControls?: Partial<ItemControls>;
rowHeight?: number;
scrollOffset?: number;
songsByAlbumId?: Record<string, Song[]>;
tableId?: string;
}
@@ -109,7 +117,13 @@ interface RowData {
getItem?: (index: number) => unknown;
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
registerSongs: (albumId: string, songs: Song[]) => void;
songsByAlbumId?: Record<string, Song[]>;
trackColumns: ItemTableListColumnConfig[];
trackTableSize: 'compact' | 'default' | 'large';
}
@@ -126,6 +140,11 @@ interface TrackRowProps {
internalState: ItemListStateActions;
isMutatingFavorite: boolean;
isSongsLoading?: boolean;
onSongRowDoubleClick?: (params: {
index: number;
internalState: ItemListStateActions;
item: Song;
}) => void;
rowIndex: number;
size: 'compact' | 'default' | 'large';
song: Song;
@@ -147,6 +166,7 @@ const TrackRow = memo(
internalState,
isMutatingFavorite,
isSongsLoading,
onSongRowDoubleClick,
rowIndex,
size,
song,
@@ -167,11 +187,37 @@ const TrackRow = memo(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onSongRowDoubleClick) {
onSongRowDoubleClick({
index: internalState.findItemIndex(song.id),
internalState,
item: song,
});
return;
}
if (controls?.onDoubleClick) {
controls.onDoubleClick({
event: e,
index: internalState.findItemIndex(song.id),
internalState,
item: song,
itemType: LibraryItem.SONG,
});
return;
}
if (isSongsLoading || albumSongs.length === 0) return;
internalState.setSelected([song]);
playerContext.addToQueueByData(albumSongs, Play.NOW, song.id);
},
[albumSongs, internalState, isSongsLoading, playerContext, song],
[
albumSongs,
controls,
internalState,
isSongsLoading,
onSongRowDoubleClick,
playerContext,
song,
],
);
const handleRowClick = useCallback(
@@ -610,7 +656,9 @@ const RowContent = memo(
index,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
}: RowContentProps) => {
@@ -622,8 +670,10 @@ const RowContent = memo(
return (data?.[index] as Album | undefined) || undefined;
}, [data, getItem, index]);
const useClientSideSongs = Boolean(songsByAlbumId);
const songListQuery = useMemo(() => {
if (!item?.id || !item?._serverId) return null;
if (useClientSideSongs || !item?.id || !item?._serverId) return null;
return {
query: {
albumIds: [item.id],
@@ -634,7 +684,7 @@ const RowContent = memo(
},
serverId: item?._serverId || '',
};
}, [item]);
}, [item, useClientSideSongs]);
const { data: songListData, isLoading: isSongsQueryLoading } = useQuery({
enabled: !!songListQuery,
@@ -646,8 +696,17 @@ const RowContent = memo(
}),
});
const songItems = songListData?.items;
const isSongsLoading = !!item && isSongsQueryLoading && !songItems?.length;
const songItemsFromQuery = songListData?.items;
const songItemsFromClient = useMemo(() => {
const rowSongs = (item as { _playlistSongs?: Song[] })?._playlistSongs;
if (rowSongs?.length) return rowSongs;
if (!songsByAlbumId || !item?.id) return undefined;
return songsByAlbumId[item.id];
}, [item, songsByAlbumId]);
const songItems = useClientSideSongs ? songItemsFromClient : songItemsFromQuery;
const isSongsLoading =
!useClientSideSongs && !!item && isSongsQueryLoading && !songItemsFromQuery?.length;
const songs = useMemo(() => {
return (
@@ -705,6 +764,7 @@ const RowContent = memo(
isMutatingFavorite={isMutatingFavorite}
isSongsLoading={isSongsLoading}
key={song.id}
onSongRowDoubleClick={onSongRowDoubleClick}
rowIndex={rowIndex}
size={trackTableSize}
song={song as Song}
@@ -729,6 +789,7 @@ const RowContent = memo(
prev.isMutatingFavorite === next.isMutatingFavorite &&
prev.controls === next.controls &&
prev.registerSongs === next.registerSongs &&
prev.songsByAlbumId === next.songsByAlbumId &&
prev.trackColumns === next.trackColumns &&
prev.trackTableSize === next.trackTableSize,
);
@@ -1113,10 +1174,14 @@ export const ItemDetailList = ({
getItem,
itemCount: externalItemCount,
items,
listKey = ItemListKey.ALBUM,
onColumnReordered,
onColumnResized,
onRangeChanged,
onScrollEnd,
onSongRowDoubleClick,
overrideControls,
songsByAlbumId,
tableId = DEFAULT_DETAIL_TABLE_ID,
}: ItemDetailListProps) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -1127,6 +1192,7 @@ export const ItemDetailList = ({
const controls = useDefaultItemListControls({
onColumnReordered,
onColumnResized,
overrides: overrideControls,
});
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
@@ -1172,7 +1238,7 @@ export const ItemDetailList = ({
const internalState = useItemListState(getDataFn, extractRowIdSong);
const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM]?.detail);
const tableConfig = useSettingsStore((state) => state.lists[listKey]?.detail);
const trackColumns = useMemo((): ItemTableListColumnConfig[] => {
const raw = tableConfig?.columns;
if (raw && raw.length > 0) {
@@ -1263,8 +1329,10 @@ export const ItemDetailList = ({
getItem,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
queryClient,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
}),
@@ -1279,8 +1347,10 @@ export const ItemDetailList = ({
getItem,
internalState,
isMutatingFavorite,
onSongRowDoubleClick,
queryClient,
registerSongs,
songsByAlbumId,
trackColumns,
trackTableSize,
],
@@ -2,8 +2,8 @@ import { TableColumn } from '/@/shared/types/types';
const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.ACTIONS]: 32,
[TableColumn.BIT_DEPTH]: 80,
[TableColumn.BIT_RATE]: 80,
[TableColumn.BIT_DEPTH]: 88,
[TableColumn.BIT_RATE]: 88,
[TableColumn.BPM]: 56,
[TableColumn.CHANNELS]: 80,
[TableColumn.CODEC]: 80,
@@ -11,8 +11,8 @@ const FIXED_TRACK_COLUMN_WIDTHS: Partial<Record<TableColumn, number>> = {
[TableColumn.DISC_NUMBER]: 36,
[TableColumn.DURATION]: 72,
[TableColumn.RELEASE_DATE]: 128,
[TableColumn.SAMPLE_RATE]: 90,
[TableColumn.TRACK_NUMBER]: 56,
[TableColumn.SAMPLE_RATE]: 112,
[TableColumn.TRACK_NUMBER]: 64,
[TableColumn.USER_FAVORITE]: 32,
[TableColumn.USER_RATING]: 64,
[TableColumn.YEAR]: 56,
+6
View File
@@ -1,16 +1,22 @@
import { createContext, useContext } from 'react';
import { LibraryItem } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
export type ListDisplayMode = LibraryItem.ALBUM | LibraryItem.SONG;
interface ListContextProps {
customFilters?: Record<string, unknown>;
displayMode?: ListDisplayMode;
id?: string;
isSidebarOpen?: boolean;
isSmartPlaylist?: boolean;
itemCount?: number;
listData?: unknown[];
listKey?: ItemListKey;
mode?: 'edit' | 'view';
pageKey: ItemListKey | string;
setDisplayMode?: (displayMode: ListDisplayMode) => void;
setIsSidebarOpen?: (isSidebarOpen: boolean) => void;
setItemCount?: (itemCount: number) => void;
setListData?: (items: unknown[]) => void;
@@ -345,8 +345,7 @@ export const AddToPlaylistAction = ({ items, itemType }: AddToPlaylistActionProp
openContextModal({
innerProps: {
itemIds: items,
resourceType: itemType,
...modalProps,
},
modalKey: 'addToPlaylist',
size: 'lg',
@@ -7,6 +7,7 @@ import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc
import { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
import { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button';
import { AutoDJHook } from '/@/renderer/features/player/hooks/use-auto-dj';
import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session';
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
@@ -48,6 +49,7 @@ export const AudioPlayers = () => {
return (
<>
<SleepTimerHook />
<ScrobbleHook />
<PowerSaveBlockerHook />
<DiscordRpcHook />
+2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { PopoverPlayQueue } from '/@/renderer/features/now-playing/components/popover-play-queue';
import { PlayerConfig } from '/@/renderer/features/player/components/player-config';
import { CustomPlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
import { SleepTimerButton } from '/@/renderer/features/player/components/sleep-timer-button';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
@@ -72,6 +73,7 @@ export const RightControls = () => {
<AutoDJButton />
</Group>
<Group align="center" gap="xs" wrap="nowrap">
<SleepTimerButton />
<PlayerConfig />
<LyricsButton />
<FavoriteButton />
@@ -0,0 +1,344 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';
import {
useSleepTimerActions,
useSleepTimerActive,
useSleepTimerMode,
useSleepTimerRemaining,
useSleepTimerStore,
} from '/@/renderer/store/sleep-timer.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Popover } from '/@/shared/components/popover/popover';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { PlayerStatus } from '/@/shared/types/types';
const PRESET_OPTIONS = [
{ minutes: 0, mode: 'endOfSong' as const },
{ minutes: 5, mode: 'timed' as const },
{ minutes: 10, mode: 'timed' as const },
{ minutes: 15, mode: 'timed' as const },
{ minutes: 30, mode: 'timed' as const },
{ minutes: 45, mode: 'timed' as const },
{ minutes: 60, mode: 'timed' as const },
{ minutes: 120, mode: 'timed' as const },
];
function formatRemaining(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = Math.floor(totalSeconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
const useSleepTimer = () => {
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const { cancelTimer, setRemaining } = useSleepTimerActions();
const { mediaPause } = usePlayer();
const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = mediaPause;
const handleOnCurrentSongChange = useCallback(() => {
if (!active) {
return;
}
// Cancel and pause on song change in end-of-song mode
if (mode === 'endOfSong') {
cancelTimer();
mediaPauseRef.current();
}
}, [active, mode, cancelTimer, mediaPauseRef]);
const status = usePlayerStatus();
const handleOnPlayerProgress = useCallback(() => {
if (!active) {
return;
}
if (status !== PlayerStatus.PLAYING) {
return;
}
// Count down in timed mode
if (mode === 'timed') {
const remaining = useSleepTimerStore.getState().remaining;
if (remaining <= 0) {
cancelTimer();
mediaPauseRef.current();
} else {
setRemaining(Math.max(0, remaining - 1));
}
}
}, [active, cancelTimer, mode, setRemaining, status]);
usePlayerEvents(
{
onCurrentSongChange: handleOnCurrentSongChange,
onPlayerProgress: handleOnPlayerProgress,
},
[handleOnCurrentSongChange, handleOnPlayerProgress],
);
// End-of-song mode: subscribe to player index changes
useEffect(() => {
if (!active || mode !== 'endOfSong') return;
const initialIndex = usePlayerStoreBase.getState().player.index;
const unsub = usePlayerStoreBase.subscribe(
(state) => state.player.index,
(index) => {
if (index !== initialIndex) {
cancelTimer();
mediaPauseRef.current();
}
},
);
return () => unsub();
}, [active, mode, cancelTimer]);
};
export const SleepTimerHookInner = () => {
useSleepTimer();
return null;
};
export const SleepTimerHook = () => {
const active = useSleepTimerActive();
if (!active) {
return null;
}
return React.createElement(SleepTimerHookInner);
};
export const SleepTimerButton = () => {
const { t } = useTranslation();
const active = useSleepTimerActive();
const mode = useSleepTimerMode();
const remaining = useSleepTimerRemaining();
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
const { mediaPause } = usePlayer();
const [showCustom, setShowCustom] = useState(false);
const [customHours, setCustomHours] = useState<number>(0);
const [customMinutes, setCustomMinutes] = useState<number>(20);
const [customSeconds, setCustomSeconds] = useState<number>(0);
const [opened, setOpened] = useState(false);
const mediaPauseRef = useRef(mediaPause);
mediaPauseRef.current = mediaPause;
const handlePreset = useCallback(
(option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') {
startEndOfSongTimer();
} else {
startTimedTimer(option.minutes * 60);
}
setShowCustom(false);
setOpened(false);
},
[startEndOfSongTimer, startTimedTimer],
);
const handleCustomStart = useCallback(() => {
const totalSeconds = customHours * 3600 + customMinutes * 60 + customSeconds;
if (totalSeconds > 0) {
startTimedTimer(totalSeconds);
setShowCustom(false);
setOpened(false);
}
}, [customHours, customMinutes, customSeconds, startTimedTimer]);
const handleCancel = useCallback(() => {
cancelTimer();
setShowCustom(false);
}, [cancelTimer]);
const getPresetLabel = (option: (typeof PRESET_OPTIONS)[number]) => {
if (option.mode === 'endOfSong') {
return t('player.sleepTimer_endOfSong', { postProcess: 'sentenceCase' });
}
if (option.minutes >= 60) {
return t('player.sleepTimer_hours', {
count: option.minutes / 60,
postProcess: 'sentenceCase',
});
}
return t('player.sleepTimer_minutes', {
count: option.minutes,
postProcess: 'sentenceCase',
});
};
return (
<Popover onChange={setOpened} opened={opened} position="top" width={260}>
<Popover.Target>
<ActionIcon
icon={active ? 'sleepTimer' : 'sleepTimerOff'}
iconProps={{
color: active ? 'primary' : undefined,
size: 'lg',
}}
onClick={(e) => {
e.stopPropagation();
setOpened((prev) => !prev);
}}
size="sm"
tooltip={{
label: t('player.sleepTimer', { postProcess: 'titleCase' }),
openDelay: 0,
}}
variant="subtle"
/>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" p="xs">
<Text fw="600" size="sm" ta="center">
{t('player.sleepTimer', { postProcess: 'titleCase' })}
</Text>
{active && (
<Flex
align="center"
direction="column"
gap={4}
mb="xs"
style={{
background: 'var(--theme-colors-surface)',
borderRadius: 'var(--theme-radius-md)',
padding: 'var(--theme-spacing-sm) var(--theme-spacing-md)',
}}
>
{mode === 'endOfSong' ? (
<Text c="primary" size="sm">
{t('player.sleepTimer_endOfSong', {
postProcess: 'sentenceCase',
})}
</Text>
) : (
<Text c="primary" fw="600" size="lg">
{formatRemaining(remaining)}
</Text>
)}
<Button
onClick={(e) => {
e.stopPropagation();
handleCancel();
}}
size="compact-xs"
variant="subtle"
>
{t('player.sleepTimer_cancel', { postProcess: 'titleCase' })}
</Button>
</Flex>
)}
{PRESET_OPTIONS.map((option, index) => (
<Button
fullWidth
justify="flex-start"
key={index}
onClick={(e) => {
e.stopPropagation();
handlePreset(option);
}}
size="xs"
variant="subtle"
>
{getPresetLabel(option)}
</Button>
))}
{!showCustom ? (
<Button
fullWidth
justify="flex-start"
onClick={(e) => {
e.stopPropagation();
setShowCustom(true);
}}
size="xs"
variant="subtle"
>
{t('player.sleepTimer_custom', { postProcess: 'sentenceCase' })}
</Button>
) : (
<Stack gap="xs">
<Group gap={4} wrap="nowrap">
<NumberInput
max={23}
min={0}
onChange={(val) => setCustomHours(Number(val) || 0)}
placeholder="hr"
size="xs"
value={customHours}
/>
<Text>:</Text>
<NumberInput
max={59}
min={0}
onChange={(val) => setCustomMinutes(Number(val) || 0)}
placeholder="min"
size="xs"
value={customMinutes}
/>
<Text>:</Text>
<NumberInput
max={59}
min={0}
onChange={(val) => setCustomSeconds(Number(val) || 0)}
placeholder="sec"
size="xs"
value={customSeconds}
/>
</Group>
<Group gap="xs" grow>
<Button
onClick={(e) => {
e.stopPropagation();
handleCustomStart();
}}
size="xs"
variant="filled"
>
{t('player.sleepTimer_setCustom', { postProcess: 'titleCase' })}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
setShowCustom(false);
}}
size="xs"
variant="default"
>
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,195 @@
import { useEffect, useMemo } from 'react';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail-list';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { type PlaylistAlbumRow, playlistSongsToAlbums } from '/@/renderer/features/playlists/utils';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { useGeneralSettings, useListSettings } from '/@/renderer/store';
import { sortSongList } from '/@/shared/api/utils';
import {
LibraryItem,
PlaylistSongListResponse,
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, ListPaginationType, Play } from '/@/shared/types/types';
export const PlaylistDetailAlbumView = ({ data }: { data: PlaylistSongListResponse }) => {
const player = usePlayer();
const { setItemCount, setListData } = useListContext();
const { detail, display, grid, itemsPerPage, pagination, table } = useListSettings(
ItemListKey.PLAYLIST_ALBUM,
);
const { enableGridMultiSelect } = useGeneralSettings();
const { currentPage, onChange: onPageChange } = useItemListPagination();
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const sortedAlbums = useMemo(() => {
let songs = data?.items ?? [];
if (searchTerm?.trim()) {
songs = searchLibraryItems(songs, searchTerm, LibraryItem.SONG);
}
const sortedSongs = sortSongList(
songs,
(query.sortBy as SongListSort) ?? SongListSort.ID,
(query.sortOrder as SortOrder) ?? SortOrder.ASC,
);
return playlistSongsToAlbums(sortedSongs);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const isPaginated = pagination === ListPaginationType.PAGINATED;
const totalAlbumCount = sortedAlbums.length;
const albumPageCount = Math.max(1, Math.ceil(totalAlbumCount / itemsPerPage));
const paginatedAlbums = useMemo(() => {
if (!isPaginated) return sortedAlbums;
const start = currentPage * itemsPerPage;
return sortedAlbums.slice(start, start + itemsPerPage);
}, [isPaginated, currentPage, itemsPerPage, sortedAlbums]);
const albumsToRender = isPaginated ? paginatedAlbums : sortedAlbums;
const playlistSongs = useMemo(() => data?.items ?? [], [data?.items]);
const albumControlOverrides = useMemo<Partial<ItemControls>>(() => {
return {
onPlay: ({
item,
itemType,
playType,
}: DefaultItemControlProps & { playType: Play }) => {
if (!item) return;
const rowSongs = (item as PlaylistAlbumRow)._playlistSongs;
if (itemType === LibraryItem.ALBUM && rowSongs?.length) {
player.addToQueueByData(rowSongs, playType);
return;
}
player.addToQueueByFetch(item._serverId, [item.id], itemType, playType);
},
};
}, [player]);
useEffect(() => {
setItemCount?.(totalAlbumCount);
}, [setItemCount, totalAlbumCount]);
useEffect(() => {
setListData?.(data?.items ?? []);
}, [data?.items, setListData]);
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ enabled: true });
const { handleColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
});
const { handleColumnReordered: handleDetailColumnReordered } = useItemListColumnReorder({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
const { handleColumnResized: handleDetailColumnResized } = useItemListColumnResize({
itemListKey: ItemListKey.PLAYLIST_ALBUM,
tableKey: 'detail',
});
const rows = useGridRows(LibraryItem.ALBUM, ItemListKey.PLAYLIST_ALBUM, grid.size);
const renderAlbumList = () => {
switch (display) {
case ListDisplayType.DETAIL:
return (
<ItemDetailList
enableHeader={detail?.enableHeader}
items={albumsToRender}
listKey={ItemListKey.PLAYLIST_ALBUM}
onColumnReordered={handleDetailColumnReordered}
onColumnResized={handleDetailColumnResized}
onScrollEnd={handleOnScrollEnd}
onSongRowDoubleClick={({ internalState, item }) => {
if (playlistSongs.length === 0) return;
internalState?.setSelected([item]);
player.addToQueueByData(playlistSongs, Play.NOW, item.id);
}}
overrideControls={albumControlOverrides}
scrollOffset={scrollOffset ?? 0}
songsByAlbumId={{}}
tableId="album-detail"
/>
);
case ListDisplayType.GRID:
return (
<ItemGridList
data={albumsToRender}
enableExpansion
enableMultiSelect={enableGridMultiSelect}
gap={grid.itemGap}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemsPerRow={grid.itemsPerRowEnabled ? grid.itemsPerRow : undefined}
itemType={LibraryItem.ALBUM}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
rows={rows}
size={grid.size}
/>
);
case ListDisplayType.TABLE:
return (
<ItemTableList
autoFitColumns={table.autoFitColumns}
CellComponent={ItemTableListColumn}
columns={table.columns}
data={albumsToRender}
enableAlternateRowColors={table.enableAlternateRowColors}
enableHeader={table.enableHeader}
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableSelection
enableVerticalBorders={table.enableVerticalBorders}
initialTop={{
to: scrollOffset ?? 0,
type: 'offset',
}}
itemType={LibraryItem.ALBUM}
onColumnReordered={handleColumnReordered}
onColumnResized={handleColumnResized}
onScrollEnd={handleOnScrollEnd}
overrideControls={albumControlOverrides}
size={table.size}
/>
);
default:
return null;
}
};
if (isPaginated) {
return (
<ItemListWithPagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
onChange={onPageChange}
pageCount={albumPageCount}
totalItemCount={totalAlbumCount}
>
{renderAlbumList()}
</ItemListWithPagination>
);
}
return renderAlbumList();
};
@@ -2,14 +2,27 @@ import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination';
import { ItemListHandle } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { PlaylistDetailAlbumView } from '/@/renderer/features/playlists/components/playlist-detail-album-view';
import { usePlaylistTrackList } from '/@/renderer/features/playlists/hooks/use-playlist-track-list';
import { useCurrentServer, useListSettings } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/shared/types/domain-types';
import { ItemListKey, ListDisplayType, TableColumn } from '/@/shared/types/types';
import {
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import {
ItemListKey,
ListDisplayType,
ListPaginationType,
TableColumn,
} from '/@/shared/types/types';
const PlaylistDetailSongListTable = lazy(() =>
import('/@/renderer/features/playlists/components/playlist-detail-song-list-table').then(
@@ -38,7 +51,6 @@ const PlaylistDetailSongListGrid = lazy(() =>
export const PlaylistDetailSongListContent = () => {
const { playlistId } = useParams() as { playlistId: string };
const server = useCurrentServer();
const { setItemCount } = useListContext();
const queryClient = useQueryClient();
const playlistSongsQuery = useSuspenseQuery(
@@ -50,18 +62,12 @@ export const PlaylistDetailSongListContent = () => {
}),
);
useEffect(() => {
if (
playlistSongsQuery.data?.totalRecordCount !== undefined &&
playlistSongsQuery.data.totalRecordCount !== null
) {
setItemCount?.(playlistSongsQuery.data.totalRecordCount);
}
}, [playlistSongsQuery.data?.totalRecordCount, setItemCount]);
useEffect(() => {
const handleRefresh = async (payload: { key: string }) => {
if (payload.key !== ItemListKey.PLAYLIST_SONG) {
if (
payload.key !== ItemListKey.PLAYLIST_SONG &&
payload.key !== ItemListKey.PLAYLIST_ALBUM
) {
return;
}
@@ -81,7 +87,7 @@ export const PlaylistDetailSongListContent = () => {
return () => {
eventEmitter.off('ITEM_LIST_REFRESH', handleRefresh);
};
}, [playlistId, queryClient, server.id]);
}, [playlistId, queryClient, server?.id]);
return (
<Suspense fallback={<Spinner container />}>
@@ -92,13 +98,36 @@ export const PlaylistDetailSongListContent = () => {
export type OverridePlaylistSongListQuery = Omit<Partial<PlaylistSongListQuery>, 'id'>;
export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListResponse }) => {
interface PlaylistDetailSongListViewProps {
data: PlaylistSongListResponse;
/** When provided, table/grid use this instead of computing from data (avoids duplicate filter/sort). */
items?: Song[];
}
export const PlaylistDetailSongListView = ({ data, items }: PlaylistDetailSongListViewProps) => {
const server = useCurrentServer();
const { display, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
const { display, itemsPerPage, pagination, table } = useListSettings(ItemListKey.PLAYLIST_SONG);
const { currentPage, onChange: onPageChange } = useItemListPagination();
const isPaginated = pagination === ListPaginationType.PAGINATED;
const paginationProps = isPaginated
? {
currentPage,
itemsPerPage,
onPageChange,
}
: undefined;
switch (display) {
case ListDisplayType.GRID: {
return <PlaylistDetailSongListGrid data={data} serverId={server.id} />;
return (
<PlaylistDetailSongListGrid
data={data}
items={items}
serverId={server.id}
{...paginationProps}
/>
);
}
case ListDisplayType.TABLE: {
return (
@@ -111,8 +140,10 @@ export const PlaylistDetailSongListView = ({ data }: { data: PlaylistSongListRes
enableHorizontalBorders={table.enableHorizontalBorders}
enableRowHoverHighlight={table.enableRowHoverHighlight}
enableVerticalBorders={table.enableVerticalBorders}
items={items}
serverId={server.id}
size={table.size}
{...paginationProps}
/>
);
}
@@ -252,19 +283,33 @@ export const PlaylistDetailSongListEdit = ({ data }: { data: PlaylistSongListRes
}
};
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
/** Track view: view mode uses centralized list derivation; edit mode uses local reorder state. */
const PlaylistDetailTrackView = ({ data }: { data: PlaylistSongListResponse }) => {
const { isSmartPlaylist, mode } = useListContext();
if (isSmartPlaylist) {
return <PlaylistDetailSongListView data={data} />;
return <PlaylistDetailTrackViewContent data={data} />;
}
switch (mode) {
case 'edit':
return <PlaylistDetailSongListEdit data={data} />;
case 'view':
return <PlaylistDetailSongListView data={data} />;
default:
return null;
if (mode === 'edit') {
return <PlaylistDetailSongListEdit data={data} />;
}
return <PlaylistDetailTrackViewContent data={data} />;
};
/** Uses usePlaylistTrackList once and passes derived items to the list view. */
const PlaylistDetailTrackViewContent = ({ data }: { data: PlaylistSongListResponse }) => {
const { sortedAndFilteredSongs } = usePlaylistTrackList(data);
return <PlaylistDetailSongListView data={data} items={sortedAndFilteredSongs} />;
};
const PlaylistDetailSongList = ({ data }: { data: PlaylistSongListResponse }) => {
const { displayMode } = useListContext();
if (displayMode === LibraryItem.ALBUM) {
return <PlaylistDetailAlbumView data={data} />;
}
return <PlaylistDetailTrackView data={data} />;
};
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemGridList } from '/@/renderer/components/item-list/item-grid-list/item-grid-list';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { ItemListGridComponentProps } from '/@/renderer/components/item-list/types';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
@@ -15,40 +16,52 @@ import {
LibraryItem,
PlaylistSongListQuery,
PlaylistSongListResponse,
Song,
} from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface PlaylistDetailSongListGridProps
extends Omit<ItemListGridComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
}
export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongListGridProps>(
({ data, saveScrollOffset = true }) => {
({
currentPage,
data,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true,
}) => {
const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({
enabled: saveScrollOffset,
});
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) {
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG);
return items;
list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return list;
}
return sortSongList(items, query.sortBy, query.sortOrder);
return sortSongList(list, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => {
if (setListData) {
setListData(songData);
if (itemsProp == null && setListData) {
setListData(songDataFromData);
}
}, [songData, setListData]);
}, [itemsProp, songDataFromData, setListData]);
const gridProps = useListSettings(ItemListKey.PLAYLIST_SONG).grid;
@@ -59,9 +72,22 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
);
const { enableGridMultiSelect } = useGeneralSettings();
return (
const isPaginated =
typeof currentPage === 'number' &&
typeof itemsPerPage === 'number' &&
typeof onPageChange === 'function';
const totalCount = songData.length;
const pageCount = Math.max(1, Math.ceil(totalCount / (itemsPerPage ?? 1)));
const paginatedData = useMemo(() => {
if (!isPaginated || currentPage == null || itemsPerPage == null) return songData;
const start = currentPage * itemsPerPage;
return songData.slice(start, start + itemsPerPage);
}, [currentPage, isPaginated, itemsPerPage, songData]);
const dataToRender = isPaginated ? paginatedData : songData;
const grid = (
<ItemGridList
data={songData}
data={dataToRender}
enableMultiSelect={enableGridMultiSelect}
gap={gridProps.itemGap}
initialTop={{
@@ -75,5 +101,21 @@ export const PlaylistDetailSongListGrid = forwardRef<any, PlaylistDetailSongList
size={gridProps.size}
/>
);
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{grid}
</ItemListWithPagination>
);
}
return grid;
},
);
@@ -5,19 +5,27 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { PLAYLIST_SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
import {
ALBUM_TABLE_COLUMNS,
PLAYLIST_SONG_TABLE_COLUMNS,
SONG_TABLE_COLUMNS,
} from '/@/renderer/components/item-list/item-table-list/default-columns';
import { useListContext } from '/@/renderer/context/list-context';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { ListDisplayTypeToggleButton } from '/@/renderer/features/shared/components/list-display-type-toggle-button';
import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button';
import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input';
import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown';
import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { useContainerQuery } from '/@/renderer/hooks';
import { useCurrentServerId } from '/@/renderer/store';
import {
PlaylistTarget,
useCurrentServerId,
usePlaylistTarget,
useSettingsStoreActions,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
@@ -37,8 +45,10 @@ export const PlaylistDetailSongListHeaderFilters = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderFiltersProps) => {
const { t } = useTranslation();
const { mode, setMode } = useListContext();
const { listKey: listKeyFromContext, mode, setMode } = useListContext();
const { playlistId } = useParams() as { playlistId: string };
const playlistTarget = usePlaylistTarget();
const { setPlaylistBehavior } = useSettingsStoreActions();
const serverId = useCurrentServerId();
const detailQuery = useQuery(playlistsQueries.detail({ query: { id: playlistId }, serverId }));
@@ -55,9 +65,25 @@ export const PlaylistDetailSongListHeaderFilters = ({
});
};
const listKey =
listKeyFromContext ??
(playlistTarget === PlaylistTarget.ALBUM
? ItemListKey.PLAYLIST_ALBUM
: ItemListKey.PLAYLIST_SONG);
const isAlbumMode = listKey === ItemListKey.PLAYLIST_ALBUM;
const toggleChoice = isAlbumMode
? t('entity.album', { count: 2, postProcess: 'titleCase' })
: t('entity.track', { count: 2, postProcess: 'titleCase' });
const handleToggleDisplayMode = useCallback(() => {
setPlaylistBehavior(
playlistTarget === PlaylistTarget.ALBUM ? PlaylistTarget.TRACK : PlaylistTarget.ALBUM,
);
}, [playlistTarget, setPlaylistBehavior]);
const { ref: containerRef, ...breakpoints } = useContainerQuery();
const isViewEditMode = !isSmartPlaylist && breakpoints.isSm;
const isViewEditMode = !isSmartPlaylist && (breakpoints.isSm || isAlbumMode);
const isEditMode = mode === 'edit';
const [collapsed, setCollapsed] = useLocalStorage<boolean>({
@@ -68,6 +94,14 @@ export const PlaylistDetailSongListHeaderFilters = ({
return (
<Flex justify="space-between" ref={containerRef}>
<Group gap="sm" w="100%">
<Button
leftSection={<Icon icon="arrowLeftRight" />}
onClick={handleToggleDisplayMode}
variant="subtle"
>
{toggleChoice}
</Button>
<Divider orientation="vertical" />
<ListSortByDropdown
defaultSortByValue={SongListSort.ID}
disabled={isEditMode}
@@ -80,8 +114,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
disabled={isEditMode}
listKey={ItemListKey.PLAYLIST_SONG}
/>
{!collapsed && <ListSearchInput />}
<ListRefreshButton disabled={isEditMode} listKey={ItemListKey.PLAYLIST_SONG} />
<ListRefreshButton disabled={isEditMode} listKey={listKey} />
<MoreButton onClick={handleMore} />
</Group>
<Group gap="sm" wrap="nowrap">
@@ -109,11 +142,25 @@ export const PlaylistDetailSongListHeaderFilters = ({
variant="subtle"
/>
</Tooltip>
<ListDisplayTypeToggleButton listKey={ItemListKey.PLAYLIST_SONG} />
<ListConfigMenu
listKey={ItemListKey.PLAYLIST_SONG}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
<ListDisplayTypeToggleButton enableDetail={isAlbumMode} listKey={listKey} />
{isAlbumMode ? (
<ListConfigMenu
detailConfig={{
optionsConfig: {
autoFitColumns: { hidden: true },
},
tableColumnsData: SONG_TABLE_COLUMNS,
tableKey: 'detail',
}}
listKey={listKey}
tableColumnsData={ALBUM_TABLE_COLUMNS}
/>
) : (
<ListConfigMenu
listKey={listKey}
tableColumnsData={PLAYLIST_SONG_TABLE_COLUMNS}
/>
)}
</Group>
</Flex>
);
@@ -93,6 +93,7 @@ export const PlaylistDetailSongListHeader = ({
</PageHeader>
) : (
<LibraryHeader
compact
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -101,6 +102,7 @@ export const PlaylistDetailSongListHeader = ({
type: LibraryItem.PLAYLIST,
}}
title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
>
<LibraryHeaderMenu
onPlay={(type) => handlePlay(type)}
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder';
import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize';
import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist';
import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination';
import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import { ItemControls, ItemListTableComponentProps } from '/@/renderer/components/item-list/types';
@@ -24,7 +25,11 @@ import { ItemListKey, Play } from '/@/shared/types/types';
interface PlaylistDetailSongListTableProps
extends Omit<ItemListTableComponentProps<PlaylistSongListQuery>, 'query'> {
currentPage?: number;
data: PlaylistSongListResponse;
items?: Song[];
itemsPerPage?: number;
onPageChange?: (page: number) => void;
}
export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongListTableProps>(
@@ -32,6 +37,7 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
{
autoFitColumns = false,
columns,
currentPage,
data,
enableAlternateRowColors = false,
enableHeader = true,
@@ -39,6 +45,9 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
enableRowHoverHighlight = true,
enableSelection = true,
enableVerticalBorders = false,
items: itemsProp,
itemsPerPage,
onPageChange,
saveScrollOffset = true,
size = 'default',
},
@@ -58,24 +67,24 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const { setListData } = useListContext();
const songData = useMemo(() => {
let items = data?.items || [];
const songDataFromData = useMemo(() => {
let list = data?.items || [];
if (searchTerm) {
items = searchLibraryItems(items, searchTerm, LibraryItem.SONG);
return items;
list = searchLibraryItems(list, searchTerm, LibraryItem.SONG);
return list;
}
return sortSongList(items, query.sortBy, query.sortOrder);
return sortSongList(list, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const { setListData } = useListContext();
const songData = itemsProp ?? songDataFromData;
useEffect(() => {
if (setListData) {
setListData(songData);
if (itemsProp == null && setListData) {
setListData(songDataFromData);
}
}, [songData, setListData]);
}, [itemsProp, songDataFromData, setListData]);
const player = usePlayer();
@@ -108,13 +117,26 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
};
}, []);
return (
const isPaginated =
typeof currentPage === 'number' &&
typeof itemsPerPage === 'number' &&
typeof onPageChange === 'function';
const totalCount = songData.length;
const pageCount = Math.max(1, Math.ceil(totalCount / (itemsPerPage ?? 1)));
const paginatedData = useMemo(() => {
if (!isPaginated || currentPage == null || itemsPerPage == null) return songData;
const start = currentPage * itemsPerPage;
return songData.slice(start, start + itemsPerPage);
}, [isPaginated, currentPage, itemsPerPage, songData]);
const dataToRender = isPaginated ? paginatedData : songData;
const table = (
<ItemTableList
activeRowId={currentSong?.id}
autoFitColumns={autoFitColumns}
CellComponent={ItemTableListColumn}
columns={columns}
data={songData}
data={dataToRender}
enableAlternateRowColors={enableAlternateRowColors}
enableExpansion={false}
enableHeader={enableHeader}
@@ -136,6 +158,22 @@ export const PlaylistDetailSongListTable = forwardRef<any, PlaylistDetailSongLis
size={size}
/>
);
if (isPaginated && itemsPerPage != null) {
return (
<ItemListWithPagination
currentPage={currentPage!}
itemsPerPage={itemsPerPage}
onChange={onPageChange!}
pageCount={pageCount}
totalItemCount={totalCount}
>
{table}
</ItemListWithPagination>
);
}
return table;
},
);
@@ -0,0 +1,36 @@
import { useEffect, useMemo } from 'react';
import { useListContext } from '/@/renderer/context/list-context';
import { usePlaylistSongListFilters } from '/@/renderer/features/playlists/hooks/use-playlist-song-list-filters';
import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter';
import { searchLibraryItems } from '/@/renderer/features/shared/utils';
import { sortSongList } from '/@/shared/api/utils';
import { LibraryItem, PlaylistSongListResponse, Song } from '/@/shared/types/domain-types';
export function usePlaylistTrackList(data: PlaylistSongListResponse | undefined): {
sortedAndFilteredSongs: Song[];
totalCount: number;
} {
const { setItemCount, setListData } = useListContext();
const { searchTerm } = useSearchTermFilter();
const { query } = usePlaylistSongListFilters();
const sortedAndFilteredSongs = useMemo(() => {
const raw = data?.items ?? [];
if (searchTerm) {
return searchLibraryItems(raw, searchTerm, LibraryItem.SONG);
}
return sortSongList(raw, query.sortBy, query.sortOrder);
}, [data?.items, searchTerm, query.sortBy, query.sortOrder]);
const totalCount = sortedAndFilteredSongs.length;
useEffect(() => {
setListData?.(sortedAndFilteredSongs);
setItemCount?.(totalCount);
}, [sortedAndFilteredSongs, totalCount, setListData, setItemCount]);
return { sortedAndFilteredSongs, totalCount };
}
@@ -20,7 +20,7 @@ import { AnimatedPage } from '/@/renderer/features/shared/components/animated-pa
import { JsonPreview } from '/@/renderer/features/shared/components/json-preview';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { PlaylistTarget, useCurrentServer, usePlaylistTarget } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
@@ -29,7 +29,7 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { ServerType, SongListSort } from '/@/shared/types/domain-types';
import { LibraryItem, ServerType, SongListSort } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
interface PlaylistQueryEditorProps {
@@ -154,14 +154,17 @@ const PlaylistQueryEditor = ({
}, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]);
return (
<div className="query-editor-container">
<Stack gap={0} h="100%" mah="30dvh" p="md" w="100%">
<Group justify="space-between" pb="md" wrap="nowrap">
<div
className="query-editor-container"
style={{ borderTop: '1px solid var(--theme-colors-border)' }}
>
<Stack gap={0} h="100%" mah="30dvh" p="sm" w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Button
leftSection={
<Icon
icon={isQueryBuilderExpanded ? 'arrowUpS' : 'arrowDownS'}
icon={isQueryBuilderExpanded ? 'arrowDownS' : 'arrowUpS'}
size="lg"
/>
}
@@ -396,6 +399,12 @@ const PlaylistDetailSongListRoute = () => {
setIsQueryBuilderExpanded(true);
};
const playlistTarget = usePlaylistTarget();
const displayMode: LibraryItem.ALBUM | LibraryItem.SONG =
playlistTarget === PlaylistTarget.ALBUM ? LibraryItem.ALBUM : LibraryItem.SONG;
const listKey =
displayMode === LibraryItem.ALBUM ? ItemListKey.PLAYLIST_ALBUM : ItemListKey.PLAYLIST_SONG;
const [itemCount, setItemCount] = useState<number | undefined>(undefined);
const [listData, setListData] = useState<unknown[]>([]);
const [mode, setMode] = useState<'edit' | 'view'>('view');
@@ -403,17 +412,19 @@ const PlaylistDetailSongListRoute = () => {
const providerValue = useMemo(() => {
return {
customFilters: undefined,
displayMode,
id: playlistId,
isSmartPlaylist,
itemCount,
listData,
listKey,
mode,
pageKey: ItemListKey.PLAYLIST_SONG,
pageKey: listKey,
setItemCount,
setListData,
setMode,
};
}, [playlistId, isSmartPlaylist, itemCount, listData, mode]);
}, [playlistId, isSmartPlaylist, displayMode, listKey, itemCount, listData, mode]);
return (
<AnimatedPage key={`playlist-detail-songList-${playlistId}`}>
@@ -429,6 +440,10 @@ const PlaylistDetailSongListRoute = () => {
onDelete={() => openDeletePlaylistModal()}
onToggleQueryBuilder={handleToggleShowQueryBuilder}
/>
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
{(isSmartPlaylist || showQueryBuilder) && (
<PlaylistQueryEditor
createPlaylistMutation={createPlaylistMutation}
@@ -441,9 +456,6 @@ const PlaylistDetailSongListRoute = () => {
queryBuilderRef={queryBuilderRef}
/>
)}
<Suspense fallback={<Spinner container />}>
<PlaylistDetailSongListContent />
</Suspense>
</ListContext.Provider>
</AnimatedPage>
);
+67
View File
@@ -1,8 +1,75 @@
import { nanoid } from 'nanoid/non-secure';
import { NDSongQueryFields } from '/@/shared/api/navidrome/navidrome-types';
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
import { QueryBuilderGroup } from '/@/shared/types/types';
export type PlaylistAlbumRow = Album & { _playlistSongs?: Song[] };
export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
if (songs.length === 0) return [];
const rows: PlaylistAlbumRow[] = [];
let group: Song[] = [songs[0]];
let prevAlbumId = songs[0].albumId;
const pushRow = (song: Song, groupSongs: Song[]) => {
rows.push({
_itemType: LibraryItem.ALBUM,
_playlistSongs: groupSongs,
_serverId: song._serverId,
_serverType: song._serverType,
albumArtistName: song.albumArtistName,
albumArtists: song.albumArtists,
artists: song.artists,
comment: song.comment,
createdAt: song.createdAt,
duration: null,
explicitStatus: song.explicitStatus,
genres: song.genres,
id: song.albumId,
imageId: song.imageId,
imageUrl: song.imageUrl,
isCompilation: song.compilation,
lastPlayedAt: song.lastPlayedAt,
mbzId: null,
mbzReleaseGroupId: null,
name: song.album ?? '',
originalDate: null,
originalYear: null,
participants: song.participants,
playCount: null,
recordLabels: [],
releaseDate: song.releaseDate,
releaseType: null,
releaseTypes: [],
releaseYear: song.releaseYear,
size: null,
songCount: null,
sortName: song.album ?? '',
tags: song.tags,
updatedAt: song.updatedAt,
userFavorite: false,
userRating: null,
version: null,
});
};
for (let i = 1; i < songs.length; i++) {
const song = songs[i];
if (song.albumId === prevAlbumId) {
group.push(song);
} else {
pushRow(group[0], group);
group = [song];
prevAlbumId = song.albumId;
}
}
pushRow(group[0], group);
return rows;
}
export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any[]) => {
if (groups.length === 0) {
return data;
@@ -109,9 +109,7 @@ export const ThemeSettings = memo(() => {
localSettings.themeSet(
e.currentTarget.checked
? 'system'
: settings.theme === AppTheme.DEFAULT_DARK
? 'dark'
: 'light',
: (getAppTheme(settings.theme).mode ?? 'dark'),
);
}
}}
@@ -138,7 +136,7 @@ export const ThemeSettings = memo(() => {
},
});
const colorScheme = theme === AppTheme.DEFAULT_DARK ? 'dark' : 'light';
const colorScheme = getAppTheme(theme).mode ?? 'dark';
setColorScheme(colorScheme);
@@ -1,3 +1,10 @@
.top-right {
position: absolute;
top: var(--theme-spacing-lg);
right: var(--theme-spacing-md);
z-index: 20;
}
.library-header {
position: relative;
display: grid;
@@ -56,6 +63,52 @@
height: 250px;
}
}
&.compact {
min-height: unset;
padding: var(--theme-spacing-md) var(--theme-spacing-xs);
:global(.item-image-placeholder) {
width: 250px !important;
height: 250px;
}
.image {
width: 250px !important;
height: 250px;
}
@container (min-width: $mantine-breakpoint-sm) {
grid-template-columns: 200px minmax(0, 1fr);
min-height: unset;
padding: var(--theme-spacing-md) var(--theme-spacing-sm);
.image {
width: 200px !important;
height: 200px;
}
:global(.item-image-placeholder) {
width: 200px !important;
height: 200px;
}
}
@container (min-width: $mantine-breakpoint-lg) {
grid-template-columns: 200px minmax(0, 1fr);
padding: var(--theme-spacing-md) var(--theme-spacing-md);
.image {
width: 200px !important;
height: 200px;
}
:global(.item-image-placeholder) {
width: 200px !important;
height: 200px;
}
}
}
}
.image-section {
@@ -32,6 +32,7 @@ import { Play } from '/@/shared/types/types';
interface LibraryHeaderProps {
children?: ReactNode;
compact?: boolean;
containerClassName?: string;
imagePlaceholderUrl?: null | string;
imageUrl?: null | string;
@@ -45,11 +46,20 @@ interface LibraryHeaderProps {
};
loading?: boolean;
title: string;
topRight?: ReactNode;
}
export const LibraryHeader = forwardRef(
(
{ children, containerClassName, imageUrl, item, title }: LibraryHeaderProps,
{
children,
compact,
containerClassName,
imageUrl,
item,
title,
topRight,
}: LibraryHeaderProps,
ref: Ref<HTMLDivElement>,
) => {
const { t } = useTranslation();
@@ -125,7 +135,15 @@ export const LibraryHeader = forwardRef(
}, [item.explicitStatus, item.imageId, item.type]);
return (
<div className={clsx(styles.libraryHeader, containerClassName)} ref={ref}>
<div
className={clsx(
styles.libraryHeader,
containerClassName,
compact && styles.compact,
)}
ref={ref}
>
{topRight && <div className={styles.topRight}>{topRight}</div>}
<div
className={styles.imageSection}
onClick={() => {
@@ -224,6 +224,11 @@ export const CLIENT_SIDE_ALBUM_FILTERS = [
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.duration', { postProcess: 'titleCase' }),
@@ -295,6 +300,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.communityRating', { postProcess: 'titleCase' }),
@@ -337,6 +347,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.artist', { postProcess: 'titleCase' }),
@@ -399,6 +414,11 @@ const ALBUM_LIST_FILTERS: Partial<
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.id', { postProcess: 'titleCase' }),
value: AlbumListSort.ID,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
+95
View File
@@ -152,6 +152,8 @@ const DiscordLinkTypeSchema = z.enum(['last_fm', 'musicbrainz', 'musicbrainz_las
const GenreTargetSchema = z.enum(['album', 'track']);
const PlaylistTargetSchema = z.enum(['album', 'track']);
const SideQueueTypeSchema = z.enum(['sideDrawerQueue', 'sideQueue']);
const SidebarPanelTypeSchema = z.enum(['queue', 'lyrics', 'visualizer']);
@@ -458,6 +460,7 @@ export const GeneralSettingsSchema = z.object({
playButtonBehavior: z.nativeEnum(Play),
playerbarOpenDrawer: z.boolean(),
playerbarSlider: PlayerbarSliderSchema,
playlistTarget: PlaylistTargetSchema,
resume: z.boolean(),
showLyricsInSidebar: z.boolean(),
showRatings: z.boolean(),
@@ -775,6 +778,11 @@ export enum PlayerbarSliderType {
WAVEFORM = 'waveform',
}
export enum PlaylistTarget {
ALBUM = 'album',
TRACK = 'track',
}
export enum SidebarItem {
ALBUMS = 'Albums',
ARTISTS = 'Artists',
@@ -829,6 +837,7 @@ export interface SettingsSlice extends z.infer<typeof SettingsStateSchema> {
setHomeItems: (item: SortableItem<HomeItem>[]) => void;
setList: (type: ItemListKey, data: DeepPartial<ItemListSettings>) => void;
setPlaybackFilters: (filters: PlayerFilter[]) => void;
setPlaylistBehavior: (target: PlaylistTarget) => void;
setSettings: (data: DeepPartial<SettingsState>) => void;
setSidebarItems: (items: SidebarItemType[]) => void;
setTable: (type: ItemListKey, data: DataTableProps) => void;
@@ -1039,6 +1048,7 @@ const initialState: SettingsState = {
barWidth: 2,
type: PlayerbarSliderType.SLIDER,
},
playlistTarget: PlaylistTarget.TRACK,
resume: true,
showLyricsInSidebar: true,
showRatings: true,
@@ -1175,6 +1185,83 @@ const initialState: SettingsState = {
size: 'default',
},
},
[ItemListKey.PLAYLIST_ALBUM]: {
detail: {
columns: pickTableColumns({
autoSizeColumns: [],
columns: SONG_TABLE_COLUMNS,
columnWidths: {
[TableColumn.ACTIONS]: 60,
[TableColumn.DURATION]: 100,
[TableColumn.TITLE]: 400,
[TableColumn.TRACK_NUMBER]: 50,
[TableColumn.USER_FAVORITE]: 60,
},
enabledColumns: [
TableColumn.TRACK_NUMBER,
TableColumn.TITLE,
TableColumn.DURATION,
TableColumn.USER_FAVORITE,
TableColumn.ACTIONS,
],
}),
enableAlternateRowColors: false,
enableHeader: true,
enableHorizontalBorders: false,
enableRowHoverHighlight: true,
enableVerticalBorders: false,
size: 'compact',
},
display: ListDisplayType.GRID,
grid: {
itemGap: 'sm',
itemsPerRow: 6,
itemsPerRowEnabled: false,
rows: pickGridRows({
alignLeftColumns: [
TableColumn.TITLE,
TableColumn.ALBUM_ARTIST,
TableColumn.YEAR,
],
columns: ALBUM_TABLE_COLUMNS,
enabledColumns: [TableColumn.TITLE, TableColumn.ALBUM_ARTIST, TableColumn.YEAR],
pickColumns: [
TableColumn.TITLE,
TableColumn.DURATION,
TableColumn.ALBUM_ARTIST,
TableColumn.BIT_RATE,
TableColumn.BPM,
TableColumn.DATE_ADDED,
TableColumn.GENRE,
TableColumn.PLAY_COUNT,
TableColumn.SONG_COUNT,
TableColumn.RELEASE_DATE,
TableColumn.LAST_PLAYED,
TableColumn.YEAR,
],
}),
size: 'default',
},
itemsPerPage: 100,
pagination: ListPaginationType.INFINITE,
table: {
autoFitColumns: true,
columns: ALBUM_TABLE_COLUMNS.map((column) => ({
align: column.align,
autoSize: column.autoSize,
id: column.value,
isEnabled: column.isEnabled,
pinned: column.pinned,
width: column.width,
})),
enableAlternateRowColors: false,
enableHeader: true,
enableHorizontalBorders: false,
enableRowHoverHighlight: true,
enableVerticalBorders: false,
size: 'default',
},
},
[LibraryItem.ALBUM]: {
detail: {
columns: pickTableColumns({
@@ -1808,6 +1895,11 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
state.playback.filters = filters;
});
},
setPlaylistBehavior: (target: PlaylistTarget) => {
set((state) => {
state.general.playlistTarget = target;
});
},
setSettings: (data) => {
set((state) => {
deepMergeIntoState(state, data);
@@ -2218,6 +2310,9 @@ export const usePlayerbarSlider = () =>
export const useGenreTarget = () => useSettingsStore((store) => store.general.genreTarget, shallow);
export const usePlaylistTarget = () =>
useSettingsStore((store) => store.general.playlistTarget, shallow);
export const useLanguage = () => useSettingsStore((state) => state.general.language, shallow);
export const useAccent = () => useSettingsStore((state) => state.general.accent, shallow);
+69
View File
@@ -0,0 +1,69 @@
import { useShallow } from 'zustand/react/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
export type SleepTimerMode = 'endOfSong' | 'timed';
interface SleepTimerActions {
cancelTimer: () => void;
setRemaining: (remaining: number) => void;
startEndOfSongTimer: () => void;
startTimedTimer: (durationSeconds: number) => void;
}
interface SleepTimerState {
/** Whether the timer is currently active */
active: boolean;
/** The mode of the timer */
mode: SleepTimerMode;
/** Remaining seconds (only ticks while playing) */
remaining: number;
}
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
(set) => ({
active: false,
cancelTimer: () => {
set({
active: false,
mode: 'timed',
remaining: 0,
});
},
mode: 'timed',
remaining: 0,
setRemaining: (remaining: number) => {
set({ remaining });
},
startEndOfSongTimer: () => {
set({
active: true,
mode: 'endOfSong',
remaining: 0,
});
},
startTimedTimer: (durationSeconds: number) => {
set({
active: true,
mode: 'timed',
remaining: durationSeconds,
});
},
}),
);
// Selectors
export const useSleepTimerActive = () => useSleepTimerStore((s) => s.active);
export const useSleepTimerMode = () => useSleepTimerStore((s) => s.mode);
export const useSleepTimerRemaining = () => useSleepTimerStore((s) => s.remaining);
export const useSleepTimerActions = () =>
useSleepTimerStore(
useShallow((s) => ({
cancelTimer: s.cancelTimer,
setRemaining: s.setRemaining,
startEndOfSongTimer: s.startEndOfSongTimer,
startTimedTimer: s.startTimedTimer,
})),
);
+82 -17
View File
@@ -151,7 +151,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
[(v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, 'asc', 'asc'],
[order, order, order],
);
break;
@@ -159,7 +159,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
[(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
[order, order, order, order],
);
break;
@@ -167,32 +167,54 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
[(v) => v.artistName?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
[order, order, order, order],
);
break;
case SongListSort.BPM:
results = orderBy(results, ['bpm'], [order]);
results = orderBy(
results,
['bpm', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.CHANNELS:
results = orderBy(results, ['channels'], [order]);
results = orderBy(
results,
['channels', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.COMMENT:
results = orderBy(
results,
['comment', 'discNumber', 'trackNumber'],
[order, order, 'asc', 'asc'],
['comment', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.DURATION:
results = orderBy(results, ['duration'], [order]);
results = orderBy(
results,
['duration', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.FAVORITED:
results = orderBy(results, ['userFavorite', (v) => v.name.toLowerCase()], [order]);
results = orderBy(
results,
[
'userFavorite',
(v) => v.name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, order, order, order],
);
break;
case SongListSort.GENRE:
@@ -204,7 +226,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
'discNumber',
'trackNumber',
],
[order, order, 'asc', 'asc'],
[order, order, order, order],
);
break;
@@ -217,11 +239,19 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
break;
case SongListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
results = orderBy(
results,
[(v) => v.name.toLowerCase(), (v) => v.album?.toLowerCase()],
[order, order],
);
break;
case SongListSort.PLAY_COUNT:
results = orderBy(results, ['playCount'], [order]);
results = orderBy(
results,
['playCount', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.RANDOM:
@@ -229,19 +259,51 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
break;
case SongListSort.RATING:
results = orderBy(results, ['userRating', (v) => v.name.toLowerCase()], [order]);
results = orderBy(
results,
[
'userRating',
(v) => v.name.toLowerCase(),
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, order, order, order],
);
break;
case SongListSort.RECENTLY_ADDED:
results = orderBy(results, ['createdAt'], [order]);
results = orderBy(
results,
[
(v) => {
const x = v.createdAt;
if (x == null) return null;
const d = new Date(x);
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
},
(v) => v.album?.toLowerCase(),
'discNumber',
'trackNumber',
],
[order, order, order, order],
);
break;
case SongListSort.RECENTLY_PLAYED:
results = orderBy(results, ['lastPlayedAt'], [order]);
results = orderBy(
results,
['lastPlayedAt', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.RELEASE_DATE:
results = orderBy(results, ['releaseDate'], [order]);
results = orderBy(
results,
['releaseDate', (v) => v.album?.toLowerCase(), 'discNumber', 'trackNumber'],
[order, order, order, order],
);
break;
case SongListSort.SORT_NAME:
@@ -252,7 +314,7 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor
results = orderBy(
results,
['releaseYear', (v) => v.album?.toLowerCase(), 'discNumber', 'track'],
[order, 'asc', 'asc', 'asc'],
[order, order, order, order],
);
break;
@@ -404,6 +466,9 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder:
case AlbumListSort.FAVORITED:
results = orderBy(results, ['starred'], [order]);
break;
case AlbumListSort.ID:
results = sortOrder === SortOrder.DESC ? [...results].reverse() : results;
break;
case AlbumListSort.NAME:
results = orderBy(results, [(v) => v.name.toLowerCase()], [order]);
break;
+4
View File
@@ -105,6 +105,8 @@ import {
LuStepForward,
LuSun,
LuTable,
LuTimer,
LuTimerOff,
LuTriangleAlert,
LuUpload,
LuUser,
@@ -237,6 +239,8 @@ export const AppIcon = {
share: LuShare2,
signIn: LuLogIn,
signOut: LuLogOut,
sleepTimer: LuTimer,
sleepTimerOff: LuTimerOff,
sort: LuArrowUpDown,
sortAsc: LuArrowUpNarrowWide,
sortDesc: LuArrowDownWideNarrow,
-5
View File
@@ -34,7 +34,6 @@ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 's
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
enableAnimation?: boolean;
isExplicit?: boolean;
}
@@ -105,7 +104,6 @@ export function BaseImage({
return (
<ImageContainer
className={clsx(containerClassName, containerPropsClassName)}
enableAnimation={enableAnimation}
isExplicit={isExplicit}
{...restContainerProps}
>
@@ -182,7 +180,6 @@ function ImageWithDebounce({
return (
<ImageContainer
className={clsx(containerClassName, containerPropsClassName)}
enableAnimation={enableAnimation}
isExplicit={isExplicit}
ref={ref}
{...restContainerProps}
@@ -216,7 +213,6 @@ function ImageWithDebounce({
return (
<ImageContainer
className={clsx(containerClassName, containerPropsClassName)}
enableAnimation={enableAnimation}
isExplicit={isExplicit}
{...restContainerProps}
>
@@ -284,7 +280,6 @@ function ImageWithViewport({
return (
<ImageContainer
className={clsx(containerClassName, containerPropsClassName)}
enableAnimation={enableAnimation}
isExplicit={isExplicit}
ref={ref}
{...restContainerProps}
+4
View File
@@ -466,6 +466,7 @@ export enum AlbumListSort {
DURATION = 'duration',
EXPLICIT_STATUS = 'explicitStatus',
FAVORITED = 'favorited',
ID = 'id',
NAME = 'name',
PLAY_COUNT = 'playCount',
RANDOM = 'random',
@@ -521,6 +522,7 @@ export const albumListSortMap: AlbumListSortMap = {
duration: undefined,
explicitStatus: undefined,
favorited: undefined,
id: undefined,
name: JFAlbumListSort.NAME,
playCount: JFAlbumListSort.PLAY_COUNT,
random: JFAlbumListSort.RANDOM,
@@ -540,6 +542,7 @@ export const albumListSortMap: AlbumListSortMap = {
duration: NDAlbumListSort.DURATION,
explicitStatus: NDAlbumListSort.EXPLICIT_STATUS,
favorited: NDAlbumListSort.STARRED,
id: undefined,
name: NDAlbumListSort.NAME,
playCount: NDAlbumListSort.PLAY_COUNT,
random: NDAlbumListSort.RANDOM,
@@ -560,6 +563,7 @@ export const albumListSortMap: AlbumListSortMap = {
duration: undefined,
explicitStatus: undefined,
favorited: undefined,
id: undefined,
name: undefined,
playCount: undefined,
random: undefined,
+1
View File
@@ -26,6 +26,7 @@ export enum ItemListKey {
GENRE_ALBUM = 'genreAlbum',
GENRE_SONG = 'genreSong',
PLAYLIST = LibraryItem.PLAYLIST,
PLAYLIST_ALBUM = 'playlistAlbum',
PLAYLIST_SONG = LibraryItem.PLAYLIST_SONG,
QUEUE_SONG = LibraryItem.QUEUE_SONG,
RADIO = 'radio',