diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json old mode 100755 new mode 100644 index a8ee4330a..8e8a9fe63 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1131,6 +1131,7 @@ "label": { "actions": "$t(common.action, {\"count\": 2})", "album": "$t(entity.album, {\"count\": 1})", + "albumGroup": "album group", "albumCount": "$t(entity.album, {\"count\": 2})", "albumArtist": "$t(entity.albumArtist, {\"count\": 1})", "artist": "$t(entity.artist, {\"count\": 1})", diff --git a/src/renderer/components/item-list/helpers/use-grid-rows.ts b/src/renderer/components/item-list/helpers/use-grid-rows.ts index 4a9c05188..ca1160f6b 100644 --- a/src/renderer/components/item-list/helpers/use-grid-rows.ts +++ b/src/renderer/components/item-list/helpers/use-grid-rows.ts @@ -49,6 +49,7 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => { [TableColumn.ALBUM]: 'album', [TableColumn.ALBUM_ARTIST]: 'albumArtists', [TableColumn.ALBUM_COUNT]: 'albumCount', + [TableColumn.ALBUM_GROUP]: null, [TableColumn.ARTIST]: 'artists', [TableColumn.BIOGRAPHY]: null, [TableColumn.BIT_DEPTH]: 'bitDepth', diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.module.css b/src/renderer/components/item-list/item-table-list/album-group-header.module.css new file mode 100644 index 000000000..90203d8b2 --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-header.module.css @@ -0,0 +1,33 @@ +.container { + display: flex; + gap: var(--theme-spacing-sm); + width: 100%; + height: 100%; + padding: 0 var(--theme-spacing-xs); +} + +.image-container { + position: relative; + box-sizing: border-box; + flex-shrink: 0; + height: 100%; + aspect-ratio: 1; + padding-top: calc(var(--theme-spacing-xs) * 0.5); +} + +.info { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0; + overflow: hidden; +} + +.album-name { + font-size: var(--theme-font-size-sm); +} + +.artist-name { + font-size: var(--theme-font-size-xs); + opacity: 0.7; +} diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.tsx b/src/renderer/components/item-list/item-table-list/album-group-header.tsx new file mode 100644 index 000000000..9ca91f9f8 --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-header.tsx @@ -0,0 +1,79 @@ +import { ReactElement, useState } from 'react'; + +import imageColumnStyles from '../item-detail-list/columns/image-column.module.css'; +import styles from './album-group-header.module.css'; +import { TableItemSize } from './item-table-list'; + +import { ItemImage } from '/@/renderer/components/item-image/item-image'; +import { PlayButton } from '/@/renderer/features/shared/components/play-button'; +import { + LONG_PRESS_PLAY_BEHAVIOR, + PlayTooltip, +} from '/@/renderer/features/shared/components/play-button-group'; +import { usePlayButtonBehavior } from '/@/renderer/store'; +import { LibraryItem, Song } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; + +interface AlbumGroupHeaderProps { + groupRowCount?: number; + onPlay?: (playType: Play) => void; + size?: 'compact' | 'large' | 'normal'; + song: Song | undefined; +} + +export const AlbumGroupHeader = ({ + groupRowCount, + onPlay, + size = 'normal', + song, +}: AlbumGroupHeaderProps): ReactElement => { + const [isHovered, setIsHovered] = useState(false); + const playButtonBehavior = usePlayButtonBehavior(); + const rowHeight = { + compact: TableItemSize.COMPACT, + large: TableItemSize.LARGE, + normal: TableItemSize.DEFAULT, + }[size]; + const infoHeight = groupRowCount !== undefined ? groupRowCount * rowHeight : undefined; + + return ( +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {isHovered && onPlay && ( +
+ + { + e.stopPropagation(); + onPlay(playButtonBehavior); + }} + onLongPress={(e) => { + e.stopPropagation(); + onPlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior]); + }} + /> + +
+ )} +
+
+
{song?.album ?? ''}
+
{song?.albumArtistName ?? ''}
+
+
+ ); +}; diff --git a/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx new file mode 100644 index 000000000..9cf308322 --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; + +import { AlbumGroupHeader } from '/@/renderer/components/item-list/item-table-list/album-group-header'; +import { + isLastInAlbumGroup, + ItemTableListInnerColumn, + TableColumnContainer, +} from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; +import { Song } from '/@/shared/types/domain-types'; +import { Play } from '/@/shared/types/types'; + +export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => { + const firstDataRow = props.enableHeader ? 1 : 0; + const item = props.getRowItem?.(props.rowIndex) as null | Song | undefined; + + const handlePlay = useCallback( + (playType: Play) => { + if (!item || !props.controls?.onDoubleClick) return; + + const isHeaderEnabled = !!props.enableHeader; + const index = isHeaderEnabled ? props.rowIndex - 1 : props.rowIndex; + + props.controls.onDoubleClick({ + event: null, + index, + internalState: (props as any).internalState, + item, + itemType: props.itemType, + meta: { playType }, + }); + }, + [item, props], + ); + + if (!item?.album) { + return
; + } + + // Check if this is the first row of a new album group (by album name) + let isFirstInGroup = true; + if (props.rowIndex > firstDataRow) { + const prevItem = props.getRowItem?.(props.rowIndex - 1) as null | Song | undefined; + // If prevItem is undefined (not loaded yet), assume same group to avoid duplicates + if (prevItem === undefined || prevItem?.album === item.album) { + isFirstInGroup = false; + } + } + + if (!isFirstInGroup) { + // For non-first rows, add border-bottom on the last row of the group + const needsBorder = + props.enableHorizontalBorders && + isLastInAlbumGroup( + props.rowIndex, + props.getRowItem, + !!props.enableHeader, + props.data.length, + ); + + return ( +
+ ); + } + + let groupRowCount = 1; + const totalDataRows = props.data.length + firstDataRow; + for (let idx = props.rowIndex + 1; idx < totalDataRows; idx++) { + const nextItem = props.getRowItem?.(idx) as null | Song | undefined; + if (!nextItem || nextItem.album !== item.album) break; + groupRowCount++; + } + + return ( + + + + ); +}; diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css index bc0c246e9..cb0702906 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.module.css @@ -6,6 +6,10 @@ height: 100%; } +.title-combined.no-image { + grid-template-columns: 1fr; +} + .text-container { display: grid; grid-template-rows: 1fr 1fr; diff --git a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx index 2dea51347..63f34793e 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-combined-column.tsx @@ -81,6 +81,7 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { const rowHeight = props.getRowHeight(props.rowIndex, props); const path = getTitlePath(props.itemType, (rowItem as any).id as string); const align = props.columns[props.columnIndex]?.align || 'start'; + const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false; const item = rowItem as any; const titleLinkProps = path @@ -94,46 +95,53 @@ export const DefaultTitleCombinedColumn = (props: ItemTableListInnerColumn) => { return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - {isHovered && ( -
- setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {isHovered && ( +
- handlePlay(playButtonBehavior, e)} - onLongPress={(e) => - handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], e) - } - /> - -
- )} -
+ + handlePlay(playButtonBehavior, e)} + onLongPress={(e) => + handlePlay( + LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], + e, + ) + } + /> + +
+ )} +
+ )}
const rowHeight = props.getRowHeight(props.rowIndex, props); const path = getTitlePath(props.itemType, (rowItem as any).id as string); const align = props.columns[props.columnIndex]?.align || 'start'; + const hasAlbumGroupColumn = props.hasAlbumGroupColumn ?? false; const item = rowItem as any; @@ -238,45 +247,52 @@ export const QueueSongTitleCombinedColumn = (props: ItemTableListInnerColumn) => return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - {isHovered && ( -
- setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {isHovered && ( +
- handlePlay(playButtonBehavior, e)} - onLongPress={(e) => - handlePlay(LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], e) - } - /> - -
- )} -
+ + handlePlay(playButtonBehavior, e)} + onLongPress={(e) => + handlePlay( + LONG_PRESS_PLAY_BEHAVIOR[playButtonBehavior], + e, + ) + } + /> + +
+ )} +
+ )}
{ const baseWidths = parsedColumns.map((c) => c.width); - // When autoSizeColumns is enabled, treat all widths as proportions and scale to fit container + // When autoSizeColumns is enabled, treat unpinned widths as proportions and scale to fit container. + // Pinned columns keep their base width so they don't get squeezed. if (autoFitColumns) { - const totalReferenceWidth = baseWidths.reduce((sum, width) => sum + width, 0); + const pinnedWidth = parsedColumns.reduce( + (sum, col, idx) => (col.pinned !== null ? sum + baseWidths[idx] : sum), + 0, + ); + const unpinnedIndices: number[] = []; + parsedColumns.forEach((col, idx) => { + if (col.pinned === null) { + unpinnedIndices.push(idx); + } + }); - if (totalReferenceWidth === 0 || totalContainerWidth === 0) { + const unpinnedReferenceWidth = unpinnedIndices.reduce( + (sum, idx) => sum + baseWidths[idx], + 0, + ); + const availableForUnpinned = totalContainerWidth - pinnedWidth; + + if (unpinnedReferenceWidth === 0 || availableForUnpinned <= 0) { return baseWidths.map((width) => Math.round(width)); } - const scaleFactor = totalContainerWidth / totalReferenceWidth; - const scaledWidths = baseWidths.map((width) => Math.round(width * scaleFactor)); + const scaleFactor = availableForUnpinned / unpinnedReferenceWidth; + const scaledWidths = baseWidths.map((width, idx) => { + if (parsedColumns[idx].pinned !== null) { + return Math.round(width); + } + return Math.round(width * scaleFactor); + }); - // Adjust for rounding errors: ensure total equals totalContainerWidth + // Adjust for rounding errors on unpinned columns only const totalScaled = scaledWidths.reduce((sum, width) => sum + width, 0); const difference = totalContainerWidth - totalScaled; - if (difference !== 0 && scaledWidths.length > 0) { - const sortedIndices = scaledWidths - .map((width, idx) => ({ idx, width })) + if (difference !== 0 && unpinnedIndices.length > 0) { + const sortedIndices = unpinnedIndices + .map((idx) => ({ idx, width: scaledWidths[idx] })) .sort((a, b) => b.width - a.width); const adjustmentPerColumn = Math.sign(difference); diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 54ca49524..fe3968480 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -30,6 +30,7 @@ import { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/it import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column'; import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column'; import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column'; +import { AlbumGroupColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-group-column'; import { ArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/artists-column'; import { ComposerColumn } from '/@/renderer/components/item-list/item-table-list/columns/composer-column'; import { CountColumn } from '/@/renderer/components/item-list/item-table-list/columns/count-column'; @@ -213,6 +214,11 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => { case TableColumn.SONG_COUNT: return ; + case TableColumn.ALBUM_GROUP: + return ( + + ); + case TableColumn.ARTIST: return ; @@ -362,6 +368,27 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED]; +export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean { + return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled); +} + +export function isLastInAlbumGroup( + rowIndex: number, + getRowItem: ((index: number) => unknown) | undefined, + enableHeader: boolean | undefined, + dataLength: number, +): boolean { + const item = getRowItem?.(rowIndex) as null | undefined | { album?: string }; + if (!item?.album) return true; + + const nextRowIndex = rowIndex + 1; + const maxRow = enableHeader ? dataLength + 1 : dataLength; + if (nextRowIndex >= maxRow) return true; + + const nextItem = getRowItem?.(nextRowIndex) as null | undefined | { album?: string }; + return !nextItem || nextItem.album !== item.album; +} + export const TableColumnTextContainer = ( props: ItemTableListColumn & { children: React.ReactNode; @@ -493,7 +520,14 @@ export const TableColumnTextContainer = ( props.enableHorizontalBorders && props.enableHeader && props.rowIndex > 0 && - (props.rowIndex === 1 || !isLastRow), + (isAlbumGroupingActive(props.columns) + ? isLastInAlbumGroup( + props.rowIndex, + props.getRowItem, + !!props.enableHeader, + props.data.length, + ) + : props.rowIndex === 1 || !isLastRow), [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, })} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} @@ -641,13 +675,24 @@ export const TableColumnContainer = ( [styles.paddingXl]: props.cellPadding === 'xl', [styles.paddingXs]: props.cellPadding === 'xs', [styles.right]: props.columns[props.columnIndex].align === 'end', - [styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight, - [styles.rowSelected]: isDataRow && isSelected, + [styles.rowHoverHighlightEnabled]: + isDataRow && + props.enableRowHoverHighlight && + props.type !== TableColumn.ALBUM_GROUP, + [styles.rowSelected]: + isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP, [styles.withHorizontalBorder]: props.enableHorizontalBorders && props.enableHeader && props.rowIndex > 0 && - (props.rowIndex === 1 || !isLastRow), + (isAlbumGroupingActive(props.columns) + ? isLastInAlbumGroup( + props.rowIndex, + props.getRowItem, + !!props.enableHeader, + props.data.length, + ) + : props.rowIndex === 1 || !isLastRow), [styles.withVerticalBorder]: props.enableVerticalBorders && !isLastColumn, })} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} @@ -898,6 +943,9 @@ export const columnLabelMap: Record = { [TableColumn.ALBUM_COUNT]: i18n.t('table.column.albumCount', { postProcess: 'upperCase', }) as string, + [TableColumn.ALBUM_GROUP]: i18n.t('table.config.label.albumGroup', { + postProcess: 'upperCase', + }) as string, [TableColumn.ARTIST]: i18n.t('table.column.artist', { postProcess: 'upperCase' }) as string, [TableColumn.BIOGRAPHY]: i18n.t('table.column.biography', { postProcess: 'upperCase', diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 4d297ecd0..a563556d1 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -94,7 +94,7 @@ const hasRequiredStateItemProperties = ( ); }; -enum TableItemSize { +export enum TableItemSize { COMPACT = 40, DEFAULT = 64, LARGE = 88, @@ -204,17 +204,6 @@ const VirtualizedTableGrid = ({ [columnWidth, pinnedLeftColumnCount], ); - const rowHeightMemoized = useCallback( - (index: number, cellProps: TableItemProps) => - getRowHeight(index + pinnedRowCount, cellProps), - [getRowHeight, pinnedRowCount], - ); - - const pinnedRightColumnWidthMemoized = useCallback( - (index: number) => columnWidth(index + pinnedLeftColumnCount + totalColumnCount), - [columnWidth, pinnedLeftColumnCount, totalColumnCount], - ); - const groupHeaderInfoByRowIndex = useMemo(() => { if (!groups || groups.length === 0) return undefined; @@ -231,6 +220,19 @@ const VirtualizedTableGrid = ({ return map; }, [groups, enableHeader]); + const rowHeightMemoized = useCallback( + (index: number, cellProps: TableItemProps) => { + const adjustedIndex = index + pinnedRowCount; + return getRowHeight(adjustedIndex, cellProps); + }, + [getRowHeight, pinnedRowCount], + ); + + const pinnedRightColumnWidthMemoized = useCallback( + (index: number) => columnWidth(index + pinnedLeftColumnCount + totalColumnCount), + [columnWidth, pinnedLeftColumnCount, totalColumnCount], + ); + const getGroupRenderData = useCallback(() => data, [data]); // Calculate pinned column widths for group header positioning @@ -349,6 +351,7 @@ const VirtualizedTableGrid = ({ controls, enableHeader, getRowHeight, + hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP), internalState, itemType, playerContext, @@ -802,6 +805,7 @@ export interface TableItemProps { getRowItem?: (rowIndex: number) => null | undefined | unknown; groupHeaderInfoByRowIndex?: Map; groups?: TableGroupHeader[]; + hasAlbumGroupColumn?: boolean; internalState: ItemListStateActions; itemType: ItemTableListProps['itemType']; onRowClick?: (item: any, event: React.MouseEvent) => void; diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx old mode 100755 new mode 100644 diff --git a/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx index 14989fe15..8baa771c5 100644 --- a/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx +++ b/src/renderer/features/playlists/components/playlist-detail-song-list-table.tsx @@ -21,7 +21,7 @@ import { PlaylistSongListResponse, Song, } from '/@/shared/types/domain-types'; -import { ItemListKey, Play } from '/@/shared/types/types'; +import { ItemListKey, Play, TableColumn } from '/@/shared/types/types'; interface PlaylistDetailSongListTableProps extends Omit, 'query'> { @@ -68,6 +68,10 @@ export const PlaylistDetailSongListTable = forwardRef col.id === TableColumn.ALBUM_GROUP && col.isEnabled, + ); + const songDataFromData = useMemo(() => { let list = data?.items || []; if (searchTerm) { @@ -117,6 +121,11 @@ export const PlaylistDetailSongListTable = forwardRef { + if (albumGroupingEnabled) return columns; + return columns.filter((col) => col.id !== TableColumn.ALBUM_GROUP); + }, [columns, albumGroupingEnabled]); + const isPaginated = typeof currentPage === 'number' && typeof itemsPerPage === 'number' && @@ -135,7 +144,7 @@ export const PlaylistDetailSongListTable = forwardRef - {item.pinned === null && ( - - - handleAutoSize(item, e.currentTarget.checked) - } - size="xs" - /> - - )} - + + handleAutoSize(item, e.currentTarget.checked)} + size="xs" + /> + } max={2000} min={0} diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 4e884240b..cb5fb7bbe 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -2239,10 +2239,44 @@ export const useSettingsStore = createWithEqualityFn()( } } + if (version <= 26) { + // Add ALBUM_GROUP column to the song table config + const listKeysToUpdate: ItemListKey[] = [ + ItemListKey.SONG, + ItemListKey.FOLDER, + ItemListKey.PLAYLIST_SONG, + ItemListKey.ALBUM_ARTIST_SONG, + ItemListKey.GENRE_SONG, + ItemListKey.QUEUE_SONG, + ItemListKey.FULL_SCREEN, + ItemListKey.SIDE_QUEUE, + ]; + + listKeysToUpdate.forEach((listKey) => { + const listConfig = state.lists[listKey as keyof typeof state.lists]; + if (listConfig?.table?.columns) { + const columns = listConfig.table.columns; + const hasAlbumGroup = columns.some( + (col) => col.id === TableColumn.ALBUM_GROUP, + ); + if (!hasAlbumGroup) { + columns.push({ + align: 'start', + autoSize: false, + id: TableColumn.ALBUM_GROUP, + isEnabled: false, + pinned: 'left', + width: 200, + }); + } + } + }); + } + return persistedState; }, name: 'store_settings', - version: 25, + version: 26, }, ), ); diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index bbc97c07c..d48fc3b88 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -158,7 +158,12 @@ export const sortSongList = (songs: Song[], sortBy: SongListSort, sortOrder: Sor case SongListSort.ALBUM_ARTIST: results = orderBy( results, - [(v) => v.albumArtists[0]?.name.toLowerCase(), 'discNumber', 'trackNumber'], + [ + (v) => v.albumArtists[0]?.name.toLowerCase(), + (v) => v.album?.toLowerCase(), + 'discNumber', + 'trackNumber', + ], [order, order, order, order], ); break; diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx old mode 100755 new mode 100644 diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index 8eec81468..e076dc558 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -166,6 +166,7 @@ export enum TableColumn { ALBUM = 'album', ALBUM_ARTIST = 'albumArtists', ALBUM_COUNT = 'albumCount', + ALBUM_GROUP = 'albumGroup', ARTIST = 'artists', BIOGRAPHY = 'biography', BIT_DEPTH = 'bitDepth',