mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
import type { KeyboardEvent } from 'react';
|
||
|
||
import { closeAllModals, openModal } from '@mantine/modals';
|
||
import clsx from 'clsx';
|
||
import { forwardRef, ReactNode, Ref, useCallback } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Link } from 'react-router';
|
||
|
||
import styles from './library-header.module.css';
|
||
|
||
import { getItemImageUrl, ItemImage } from '/@/renderer/components/item-image/item-image';
|
||
import { useIsPlayerFetching } from '/@/renderer/features/player/context/player-context';
|
||
import {
|
||
PlayLastTextButton,
|
||
PlayNextTextButton,
|
||
PlayTextButton,
|
||
} from '/@/renderer/features/shared/components/play-button';
|
||
import { LONG_PRESS_PLAY_BEHAVIOR } from '/@/renderer/features/shared/components/play-button-group';
|
||
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
|
||
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
||
import { useGeneralSettings } from '/@/renderer/store';
|
||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||
import { Button } from '/@/shared/components/button/button';
|
||
import { Center } from '/@/shared/components/center/center';
|
||
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||
import { Group } from '/@/shared/components/group/group';
|
||
import { Icon } from '/@/shared/components/icon/icon';
|
||
import { BaseImage } from '/@/shared/components/image/image';
|
||
import { Rating } from '/@/shared/components/rating/rating';
|
||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||
import { Text } from '/@/shared/components/text/text';
|
||
import { ExplicitStatus, LibraryItem } from '/@/shared/types/domain-types';
|
||
import { Play } from '/@/shared/types/types';
|
||
|
||
interface LibraryHeaderProps {
|
||
children?: ReactNode;
|
||
compact?: boolean;
|
||
containerClassName?: string;
|
||
imageOverlay?: ReactNode;
|
||
imagePlaceholderUrl?: null | string;
|
||
imageUrl?: null | string;
|
||
item: {
|
||
children?: ReactNode;
|
||
explicitStatus?: ExplicitStatus | null;
|
||
imageId?: null | string;
|
||
imageUrl?: null | string;
|
||
route: string;
|
||
type?: LibraryItem;
|
||
};
|
||
loading?: boolean;
|
||
onImageFileDrop?: (file: File) => Promise<void> | void;
|
||
title: string;
|
||
topRight?: ReactNode;
|
||
}
|
||
|
||
export const LibraryHeader = forwardRef(
|
||
(
|
||
{
|
||
children,
|
||
compact,
|
||
containerClassName,
|
||
imageOverlay,
|
||
imageUrl,
|
||
item,
|
||
onImageFileDrop,
|
||
title,
|
||
topRight,
|
||
}: LibraryHeaderProps,
|
||
ref: Ref<HTMLDivElement>,
|
||
) => {
|
||
const { t } = useTranslation();
|
||
const { blurExplicitImages } = useGeneralSettings();
|
||
|
||
const itemTypeString = (): string => {
|
||
switch (item.type) {
|
||
case LibraryItem.ALBUM:
|
||
return t('entity.album', { count: 1 });
|
||
case LibraryItem.ALBUM_ARTIST:
|
||
return t('entity.albumArtist', { count: 1 });
|
||
case LibraryItem.ARTIST:
|
||
return t('entity.artist', { count: 1 });
|
||
case LibraryItem.PLAYLIST:
|
||
return t('entity.playlist', { count: 1 });
|
||
case LibraryItem.SONG:
|
||
return t('entity.track', { count: 1 });
|
||
default:
|
||
return t('common.unknown');
|
||
}
|
||
};
|
||
|
||
const openImage = useCallback(() => {
|
||
const imageId = item.imageId;
|
||
const itemType = item.type as LibraryItem;
|
||
|
||
if (!imageId || !itemType) {
|
||
return;
|
||
}
|
||
|
||
const imageUrl = getItemImageUrl({
|
||
id: imageId,
|
||
itemType,
|
||
});
|
||
|
||
if (!imageUrl) {
|
||
console.error('No image URL found');
|
||
return;
|
||
}
|
||
|
||
openModal({
|
||
children: (
|
||
<Center
|
||
onClick={() => closeAllModals()}
|
||
style={{
|
||
cursor: 'pointer',
|
||
height: 'calc(100vh - 80px)',
|
||
width: '100%',
|
||
}}
|
||
>
|
||
<BaseImage
|
||
alt="cover"
|
||
enableDebounce={false}
|
||
enableViewport={false}
|
||
fetchPriority="high"
|
||
isExplicit={
|
||
blurExplicitImages &&
|
||
item.explicitStatus === ExplicitStatus.EXPLICIT
|
||
}
|
||
src={imageUrl}
|
||
style={{
|
||
maxHeight: '100%',
|
||
maxWidth: '100%',
|
||
objectFit: 'contain',
|
||
}}
|
||
unloaderIcon="emptyImage"
|
||
/>
|
||
</Center>
|
||
),
|
||
fullScreen: true,
|
||
});
|
||
}, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]);
|
||
|
||
const imageSectionSharedProps = {
|
||
onClick: () => {
|
||
openImage();
|
||
},
|
||
onKeyDown: (event: KeyboardEvent) =>
|
||
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage(),
|
||
role: 'button' as const,
|
||
style: { cursor: 'pointer' as const },
|
||
tabIndex: 0,
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={clsx(
|
||
styles.libraryHeader,
|
||
containerClassName,
|
||
compact && styles.compact,
|
||
)}
|
||
ref={ref}
|
||
>
|
||
{topRight && <div className={styles.topRight}>{topRight}</div>}
|
||
{onImageFileDrop ? (
|
||
<DragDropZone
|
||
accept="image/*"
|
||
className={styles.imageSection}
|
||
mode="file"
|
||
onFileSelected={(file) => void onImageFileDrop(file)}
|
||
{...imageSectionSharedProps}
|
||
>
|
||
<ItemImage
|
||
className={styles.image}
|
||
containerClassName={styles.image}
|
||
enableDebounce={false}
|
||
enableViewport={false}
|
||
explicitStatus={item.explicitStatus ?? null}
|
||
fetchPriority="high"
|
||
id={item.imageId}
|
||
itemType={item.type as LibraryItem}
|
||
src={imageUrl || ''}
|
||
type="header"
|
||
/>
|
||
{imageOverlay && (
|
||
<div
|
||
className={styles.imageOverlay}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
role="presentation"
|
||
>
|
||
{imageOverlay}
|
||
</div>
|
||
)}
|
||
</DragDropZone>
|
||
) : (
|
||
<div className={styles.imageSection} {...imageSectionSharedProps}>
|
||
<ItemImage
|
||
className={styles.image}
|
||
containerClassName={styles.image}
|
||
enableDebounce={false}
|
||
enableViewport={false}
|
||
explicitStatus={item.explicitStatus ?? null}
|
||
fetchPriority="high"
|
||
id={item.imageId}
|
||
itemType={item.type as LibraryItem}
|
||
src={imageUrl || ''}
|
||
type="header"
|
||
/>
|
||
{imageOverlay && (
|
||
<div
|
||
className={styles.imageOverlay}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
role="presentation"
|
||
>
|
||
{imageOverlay}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{title && (
|
||
<div className={styles.metadataSection}>
|
||
{item.children ? (
|
||
<div className={styles.itemType}>{item.children}</div>
|
||
) : (
|
||
<Text
|
||
className={styles.itemType}
|
||
component={Link}
|
||
fw={600}
|
||
isLink
|
||
size="md"
|
||
to={item.route}
|
||
tt="uppercase"
|
||
>
|
||
{itemTypeString()}
|
||
</Text>
|
||
)}
|
||
|
||
<h1
|
||
className={styles.title}
|
||
style={{
|
||
fontSize: calculateTitleSize(title),
|
||
}}
|
||
>
|
||
{title}
|
||
</h1>
|
||
{children}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
);
|
||
|
||
export const isAsianCharacter = (char: string): boolean => {
|
||
const codePoint = char.codePointAt(0);
|
||
|
||
if (!codePoint) return false;
|
||
|
||
// CJK Unified Ideographs: U+4E00–U+9FFF
|
||
if (codePoint >= 0x4e00 && codePoint <= 0x9fff) return true;
|
||
|
||
// Hiragana: U+3040–U+309F
|
||
if (codePoint >= 0x3040 && codePoint <= 0x309f) return true;
|
||
|
||
// Katakana: U+30A0–U+30FF
|
||
if (codePoint >= 0x30a0 && codePoint <= 0x30ff) return true;
|
||
|
||
// CJK Extension A: U+3400–U+4DBF
|
||
if (codePoint >= 0x3400 && codePoint <= 0x4dbf) return true;
|
||
|
||
// CJK Compatibility Ideographs: U+F900–U+FAFF
|
||
if (codePoint >= 0xf900 && codePoint <= 0xfaff) return true;
|
||
|
||
// Fullwidth forms (some Asian characters): U+FF00–U+FFEF
|
||
// Only count fullwidth letters/numbers as Asian
|
||
if (codePoint >= 0xff01 && codePoint <= 0xff5e) return true;
|
||
|
||
return false;
|
||
};
|
||
|
||
export const calculateWeightedLength = (str: string): number => {
|
||
let length = 0;
|
||
for (const char of str) {
|
||
length += isAsianCharacter(char) ? 2.5 : 1;
|
||
}
|
||
return length;
|
||
};
|
||
|
||
export const calculateTitleSize = (title: string) => {
|
||
const titleLength = calculateWeightedLength(title);
|
||
let baseSize = '3dvw';
|
||
|
||
if (titleLength > 20) {
|
||
baseSize = '2.5dvw';
|
||
}
|
||
|
||
if (titleLength > 30) {
|
||
baseSize = '2.25dvw';
|
||
}
|
||
|
||
if (titleLength > 40) {
|
||
baseSize = '2dvw';
|
||
}
|
||
|
||
if (titleLength > 50) {
|
||
baseSize = '1.875dvw';
|
||
}
|
||
|
||
if (titleLength > 60) {
|
||
baseSize = '1.75dvw';
|
||
}
|
||
|
||
if (titleLength > 70) {
|
||
baseSize = '1.5dvw';
|
||
}
|
||
|
||
if (titleLength > 80) {
|
||
baseSize = '1.4dvw';
|
||
}
|
||
|
||
if (titleLength > 90) {
|
||
baseSize = '1.3dvw';
|
||
}
|
||
|
||
return `clamp(1.75rem, ${baseSize}, 2.75rem)`;
|
||
};
|
||
|
||
interface LibraryHeaderMenuProps {
|
||
favorite?: boolean;
|
||
onAlbumRadio?: () => void;
|
||
onArtistRadio?: () => void;
|
||
onFavorite?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||
onMore?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||
onPlay?: (type: Play) => void;
|
||
onRating?: (rating: number) => void;
|
||
onShuffle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||
rating?: number;
|
||
}
|
||
|
||
export const LibraryHeaderMenu = ({
|
||
favorite,
|
||
onAlbumRadio,
|
||
onArtistRadio,
|
||
onFavorite,
|
||
onMore,
|
||
onPlay,
|
||
onRating,
|
||
rating,
|
||
}: LibraryHeaderMenuProps) => {
|
||
const { t } = useTranslation();
|
||
const isMutatingRating = useIsMutatingRating();
|
||
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
|
||
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
|
||
const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite;
|
||
const isPlayerFetching = useIsPlayerFetching();
|
||
|
||
const handlePlayNow = usePlayButtonClick({
|
||
onClick: () => {
|
||
onPlay?.(Play.NOW);
|
||
},
|
||
onLongPress: () => {
|
||
onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.NOW]);
|
||
},
|
||
});
|
||
|
||
const handlePlayNext = usePlayButtonClick({
|
||
onClick: () => {
|
||
onPlay?.(Play.NEXT);
|
||
},
|
||
onLongPress: () => {
|
||
onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.NEXT]);
|
||
},
|
||
});
|
||
|
||
const handlePlayLast = usePlayButtonClick({
|
||
onClick: () => {
|
||
onPlay?.(Play.LAST);
|
||
},
|
||
onLongPress: () => {
|
||
onPlay?.(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]);
|
||
},
|
||
});
|
||
|
||
return (
|
||
<div className={styles.libraryHeaderMenu}>
|
||
<Group wrap="nowrap">
|
||
{onPlay && <PlayTextButton {...handlePlayNow.handlers} {...handlePlayNow.props} />}
|
||
{onPlay && (
|
||
<PlayNextTextButton {...handlePlayNext.handlers} {...handlePlayNext.props} />
|
||
)}
|
||
{onPlay && (
|
||
<PlayLastTextButton {...handlePlayLast.handlers} {...handlePlayLast.props} />
|
||
)}
|
||
{onAlbumRadio && (
|
||
<Button
|
||
disabled={isPlayerFetching}
|
||
leftSection={
|
||
isPlayerFetching ? (
|
||
<Spinner color="white" />
|
||
) : (
|
||
<Icon icon="radio" size="lg" />
|
||
)
|
||
}
|
||
onClick={onAlbumRadio}
|
||
size="md"
|
||
variant="transparent"
|
||
>
|
||
{t('player.albumRadio', { postProcess: 'sentenceCase' })}
|
||
</Button>
|
||
)}
|
||
{onArtistRadio && (
|
||
<Button
|
||
disabled={isPlayerFetching}
|
||
leftSection={
|
||
isPlayerFetching ? (
|
||
<Spinner color="white" />
|
||
) : (
|
||
<Icon icon="radio" size="lg" />
|
||
)
|
||
}
|
||
onClick={onArtistRadio}
|
||
size="md"
|
||
variant="transparent"
|
||
>
|
||
{t('player.artistRadio', { postProcess: 'sentenceCase' })}
|
||
</Button>
|
||
)}
|
||
</Group>
|
||
<Group gap="sm" wrap="nowrap">
|
||
{onRating && (
|
||
<Rating
|
||
onChange={onRating}
|
||
readOnly={isMutatingRating}
|
||
size="lg"
|
||
value={rating || 0}
|
||
/>
|
||
)}
|
||
{onFavorite && (
|
||
<ActionIcon
|
||
disabled={isMutatingFavorite}
|
||
icon="favorite"
|
||
iconProps={{
|
||
fill: favorite ? 'primary' : undefined,
|
||
}}
|
||
onClick={onFavorite}
|
||
size="lg"
|
||
variant="transparent"
|
||
/>
|
||
)}
|
||
{onMore && (
|
||
<ActionIcon
|
||
icon="ellipsisHorizontal"
|
||
onClick={onMore}
|
||
size="lg"
|
||
variant="transparent"
|
||
/>
|
||
)}
|
||
</Group>
|
||
</div>
|
||
);
|
||
};
|