chuni: add music detail page

This commit is contained in:
sk1982 2024-03-14 17:24:02 -04:00
parent 1424be95fc
commit e43f0f680d
15 changed files with 390 additions and 65 deletions

4
.idea/watcherTasks.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

View File

@ -14,6 +14,8 @@ export type GetPlaylogOptions = {
export async function getPlaylog(opts: GetPlaylogOptions) { export async function getPlaylog(opts: GetPlaylogOptions) {
const user = await requireUser(); const user = await requireUser();
const musicId = 'musicId' in opts ? +opts.musicId : NaN;
const chartId = 'chartId' in opts ? +opts.chartId : NaN;
const playlog = await db.with('p', db => db const playlog = await db.with('p', db => db
.selectFrom('chuni_score_playlog as playlog') .selectFrom('chuni_score_playlog as playlog')
@ -41,8 +43,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
) )
.selectFrom('p') .selectFrom('p')
.where(({ and, eb }) => and([ .where(({ and, eb }) => and([
...('musicId' in opts ? [eb('p.songId', '=', opts.musicId)] : []), ...(!Number.isNaN(musicId) ? [eb('p.songId', '=', musicId)] : []),
...('chartId' in opts ? [eb('p.chartId', '=', opts.chartId)] : []), ...(!Number.isNaN(chartId) ? [eb('p.chartId', '=', chartId)] : []),
])) ]))
.selectAll() .selectAll()
.limit(+opts.limit) .limit(+opts.limit)
@ -54,8 +56,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) {
.where(({ and, eb }) => and([ .where(({ and, eb }) => and([
eb('playlog.user', '=', user.id), eb('playlog.user', '=', user.id),
eb('playlog.id', '<', playlog.at(-1)!.id), eb('playlog.id', '<', playlog.at(-1)!.id),
...('musicId' in opts ? [eb('playlog.musicId', '=', opts.musicId)] : []), ...(!Number.isNaN(musicId) ? [eb('playlog.musicId', '=', musicId)] : []),
...('chartId' in opts ? [eb('playlog.level', '=', opts.chartId)] : []), ...(!Number.isNaN(chartId) ? [eb('playlog.level', '=', chartId)] : []),
])) ]))
.select(({ fn }) => fn.countAll().as('remaining')) .select(({ fn }) => fn.countAll().as('remaining'))
.executeTakeFirstOrThrow()).remaining); .executeTakeFirstOrThrow()).remaining);

View File

@ -0,0 +1,36 @@
import { getMusic } from '@/actions/chuni/music';
import { notFound } from 'next/navigation';
import { MusicPlayer } from '@/components/music-player';
import { getJacketUrl, getMusicUrl } from '@/helpers/assets';
import { Ticker } from '@/components/ticker';
import { getPlaylog } from '@/actions/chuni/playlog';
import { Accordion, AccordionItem } from '@nextui-org/react';
import { ChuniMusicPlaylog } from '@/components/chuni/music-playlog';
export default async function ChuniMusicDetail({ params }: { params: { musicId: string } }) {
const musicId = parseInt(params.musicId);
if (Number.isNaN(musicId))
return notFound();
const [music, playlog] = await Promise.all([
getMusic(musicId),
getPlaylog({ musicId, limit: 500 })
]);
if (!music.length)
return notFound();
const cueId = music[0].jacketPath?.match(/UI_Jacket_(\d+)/)?.[1];
return (<div className="flex flex-col items-center sm:mt-2">
<MusicPlayer className="xl:self-start xl:mt-3 xl:ml-3 mb-3 sm:mb-6" image={getJacketUrl(`chuni/jacket/${music[0].jacketPath}`)}
audio={getMusicUrl(`chuni/music/music${cueId?.padStart(4, '0')}`)}>
<Ticker className="font-semibold text-center sm:text-left">{ music[0].title }</Ticker>
<Ticker className="text-center sm:text-left">{ music[0].artist }</Ticker>
<span className="text-medium">{ music[0].genre }</span>
</MusicPlayer>
<ChuniMusicPlaylog music={music} playlog={playlog} />
</div>);
}

View File

@ -9,10 +9,14 @@ import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars';
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container'; import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
import { getJacketUrl } from '@/helpers/assets'; import { getJacketUrl } from '@/helpers/assets';
import { ChuniLevelBadge } from '@/components/chuni/level-badge'; import { ChuniLevelBadge } from '@/components/chuni/level-badge';
import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge'; import { ChuniScoreBadge, getVariantFromLamp, getVariantFromRank } from '@/components/chuni/score-badge';
import { ChuniRating } from '@/components/chuni/rating'; import { ChuniRating } from '@/components/chuni/rating';
import Link from 'next/link'; import Link from 'next/link';
import { Squares2X2Icon } from '@heroicons/react/24/outline'; import { Squares2X2Icon } from '@heroicons/react/24/outline';
import { Ticker } from '@/components/ticker';
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
const getLevelFromStop = (n: number) => { const getLevelFromStop = (n: number) => {
if (n < 7) if (n < 7)
@ -55,25 +59,6 @@ const searcher = (query: string, data: ChuniMusicListProps['music'][number]) =>
return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query); return data.title?.toLowerCase().includes(query) || data.artist?.toLowerCase().includes(query);
}; };
const SCORES = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'];
// TODO: check if these are correct
const LAMPS = new Map([
[1, 'Clear'],
[2, 'Hard'],
[3, 'Absolute'],
[4, 'Absolute+'],
[5, 'Absolute++'],
[6, 'Catastrophy']
]);
const LAMP_DISPLAY = {
1: 'gold',
2: 'gold',
3: 'gold',
4: 'platinum',
5: 'platinum',
6: 'platinum'
};
const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) => { const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) => {
let itemWidth = 0; let itemWidth = 0;
let itemHeight = 0; let itemHeight = 0;
@ -81,12 +66,12 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
if (size === 'sm') { if (size === 'sm') {
itemWidth = 175; itemWidth = 175;
itemHeight = 225; itemHeight = 235;
itemClass = 'w-[175px] h-[230px] py-1.5 px-1'; itemClass = 'w-[175px] h-[235px] py-1.5 px-1';
} else { } else {
itemWidth = 285; itemWidth = 285;
itemHeight = 360; itemHeight = 375;
itemClass = 'w-[285px] h-[360px] py-1.5 px-1'; itemClass = 'w-[285px] h-[375px] py-1.5 px-1';
} }
const listRef = useRef<List | null>(null); const listRef = useRef<List | null>(null);
@ -106,7 +91,7 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
onScroll={onChildScroll} scrollTop={scrollTop} ref={listRef} onScroll={onChildScroll} scrollTop={scrollTop} ref={listRef}
rowRenderer={({ index, key, style }) => <div key={key} style={style} className="w-full h-full flex justify-center"> rowRenderer={({ index, key, style }) => <div key={key} style={style} className="w-full h-full flex justify-center">
{music.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map(item => <div key={`${item.songId}-${item.chartId}`} className={itemClass}> {music.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map(item => <div key={`${item.songId}-${item.chartId}`} className={itemClass}>
<ChuniDifficultyContainer difficulty={item.chartId!} containerClassName="flex flex-col" className="w-full h-full border border-gray-500/75 rounded-md"> <ChuniDifficultyContainer difficulty={item.chartId!} containerClassName="flex flex-col" className="w-full h-full border border-gray-500/75 rounded-md [&:hover_.ticker]:[animation-play-state:running]">
<div className="aspect-square w-full p-[0.2rem] relative"> <div className="aspect-square w-full p-[0.2rem] relative">
<img src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)} alt={item.title ?? 'Music'} className="rounded" /> <img src={getJacketUrl(`chuni/jacket/${item.jacketPath}`)} alt={item.title ?? 'Music'} className="rounded" />
{item.rating && !item.worldsEndTag && <div className={`${size === 'sm' ? '' : 'text-2xl'} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm px-0.5 rounded`}> {item.rating && !item.worldsEndTag && <div className={`${size === 'sm' ? '' : 'text-2xl'} absolute bottom-0.5 left-0.5 bg-gray-200/60 backdrop-blur-sm px-0.5 rounded`}>
@ -118,8 +103,8 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
</div> </div>
<div className="px-0.5 mb-1 flex"> <div className="px-0.5 mb-1 flex">
{size === 'lg' && <div className="h-full w-1/3 mr-0.5"> {size === 'lg' && <div className="h-full w-1/3 mr-0.5">
{item.isSuccess ? <ChuniScoreBadge variant={LAMP_DISPLAY[item.isSuccess]} className="h-full"> {item.isSuccess ? <ChuniScoreBadge variant={getVariantFromLamp(item.isSuccess)} className="h-full">
{LAMPS.get(item.isSuccess)} {CHUNI_LAMPS.get(item.isSuccess)}
</ChuniScoreBadge> : null} </ChuniScoreBadge> : null}
</div>} </div>}
@ -136,13 +121,10 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
</div> </div>
</div> </div>
<Link href={`/chuni/music/${item.songId}`} <Link href={`/chuni/music/${item.songId}`}
className={`${size === 'sm' ? 'text-xs' : 'text-lg'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold text-nowrap overflow-hidden drop-shadow-lg`} className={`${size === 'sm' ? 'text-xs' : 'text-lg'} mt-auto px-1 block text-white hover:text-gray-200 transition text-center font-semibold drop-shadow-lg`}>
lang="ja"> <Ticker hoverOnly noDelay>{item.title}</Ticker>
{item.title}
</Link> </Link>
<div className={`${size === 'sm' ? 'text-xs mb-0.5' : 'text-medium mb-1.5'} px-1 text-white text-center text-nowrap overflow-hidden drop-shadow-lg`} lang="ja"> <Ticker className={`${size === 'sm' ? 'text-xs mb-0.5' : 'text-medium mb-1.5'} text-center px-1 drop-shadow-2xl`} hoverOnly noDelay>{item.artist}</Ticker>
{item.artist}
</div>
</ChuniDifficultyContainer> </ChuniDifficultyContainer>
</div>)} </div>)}
</div>} />) </div>} />)
@ -167,14 +149,9 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
value: new Set<string>(), value: new Set<string>(),
className: 'col-span-6 md:col-span-3 5xl:col-span-1', className: 'col-span-6 md:col-span-3 5xl:col-span-1',
props: { props: {
children: [ children: CHUNI_DIFFICULTIES.map((name, i) => <SelectItem key={i.toString()} value={i.toString()}>
<SelectItem key="0" value="0">Basic</SelectItem>, {name}
<SelectItem key="1" value="1">Advanced</SelectItem>, </SelectItem>),
<SelectItem key="2" value="2">Expert</SelectItem>,
<SelectItem key="3" value="3">Master</SelectItem>,
<SelectItem key="4" value="4">Ultima</SelectItem>,
<SelectItem key="5" value="5">World&apos;s End</SelectItem>,
],
selectionMode: 'multiple' selectionMode: 'multiple'
}, },
filter: (val: Set<string>, data) => !val.size || val.has(data.chartId?.toString()!) filter: (val: Set<string>, data) => !val.size || val.has(data.chartId?.toString()!)
@ -200,14 +177,14 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
children: [ children: [
<SelectItem key="aj" value="aj">All Justice</SelectItem>, <SelectItem key="aj" value="aj">All Justice</SelectItem>,
<SelectItem key="fc" value="fc">Full Combo</SelectItem>, <SelectItem key="fc" value="fc">Full Combo</SelectItem>,
...[...LAMPS].map(([id, name]) => <SelectItem key={id.toString()} value={id.toString()}>{name}</SelectItem>) ...[...CHUNI_LAMPS].map(([id, name]) => <SelectItem key={id.toString()} value={id.toString()}>{name}</SelectItem>)
], ],
selectionMode: 'multiple' selectionMode: 'multiple'
}, },
filter: (val: Set<string>, data) => { filter: (val: Set<string>, data) => {
if (!val.size) return true; if (!val.size) return true;
const checkLamps = [...LAMPS].some(([id]) => val.has(id.toString())); const checkLamps = [...CHUNI_LAMPS].some(([id]) => val.has(id.toString()));
if (checkLamps && (!data.isSuccess || !val.has(data.isSuccess.toString()))) if (checkLamps && (!data.isSuccess || !val.has(data.isSuccess.toString())))
return false return false
@ -238,7 +215,7 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => {
value: new Set<string>(), value: new Set<string>(),
className: 'col-span-full sm:col-span-6 md:col-span-4 lg:col-span-2 xl:col-span-2 5xl:col-span-1', className: 'col-span-full sm:col-span-6 md:col-span-4 lg:col-span-2 xl:col-span-2 5xl:col-span-1',
props: { props: {
children: SCORES children: CHUNI_SCORE_RANKS
.map((s, i) => <SelectItem key={i.toString()} value={i.toString()}>{s}</SelectItem>) .map((s, i) => <SelectItem key={i.toString()} value={i.toString()}>{s}</SelectItem>)
.reverse(), .reverse(),
selectionMode: 'multiple' selectionMode: 'multiple'

View File

@ -0,0 +1,89 @@
'use client';
import { getMusic } from '@/actions/chuni/music';
import { getPlaylog } from '@/actions/chuni/playlog';
import { Accordion, AccordionItem } from '@nextui-org/react';
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
import { ChuniRating } from '@/components/chuni/rating';
import { ChuniScoreBadge, getVariantFromLamp, getVariantFromRank } from '@/components/chuni/score-badge';
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
import { useState } from 'react';
type ChuniMusicPlaylogProps = {
music: Awaited<ReturnType<typeof getMusic>>,
playlog: Awaited<ReturnType<typeof getPlaylog>>
};
export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => {
type Music = (typeof music)[number];
type Playlog = (typeof playlog)['data'][number];
const defaultExpanded: Record<string, Set<string>> = {}
const difficulties: (Music & { playlog: Playlog[] })[] = [];
music.forEach(m => {
difficulties[m.chartId!] = { ...m, playlog: [] };
});
playlog.data.forEach(play => {
defaultExpanded[play.chartId!] = new Set();
difficulties[play.chartId!].playlog.push(play);
});
const [expanded, setExpanded] = useState(defaultExpanded);
const badgeClass = 'h-6 sm:h-8';
return (<div className="flex flex-col w-full px-2 sm:px-0">
{difficulties.map((data, i) => {
const badges = [
!!data.scoreRank && <ChuniScoreBadge variant={getVariantFromRank(data.scoreRank)} className={badgeClass} key="1">
{CHUNI_SCORE_RANKS[data.scoreRank]}
</ChuniScoreBadge>,
!!data.isSuccess && <ChuniScoreBadge variant={getVariantFromLamp(data.isSuccess)} className={badgeClass} key="2">
{CHUNI_LAMPS.get(data.isSuccess)}
</ChuniScoreBadge>,
!!data.isFullCombo && !data.isAllJustice && <ChuniScoreBadge variant="gold" className={badgeClass} key="3">
Full Combo
</ChuniScoreBadge>,
!!data.isAllJustice && <ChuniScoreBadge variant="platinum" className={badgeClass} key="4">
All Justice
</ChuniScoreBadge>,
].filter(x => x);
const toggleExpanded = () => expanded[i] && setExpanded(e =>
({ ...e,
[i]: e[i].size ? new Set() : new Set(['1'])
}));
return (<div key={i} className="mb-2 border-b pb-2 border-gray-500 flex flex-row flex-wrap">
<div className={`flex items-center gap-2 flex-wrap w-full lg:w-auto lg:flex-grow ${data.playlog.length ? 'cursor-pointer' : ''}`} onClick={toggleExpanded}>
<div className="flex items-center">
<div className="w-14 mr-2 p-0.5 bg-black">
<ChuniLevelBadge className="w-full" music={data} />
</div>
<div className="text-xl font-semibold">{CHUNI_DIFFICULTIES[i]}</div>
</div>
{!data.playlog.length && <div className="text-right italic text-gray-500 flex-grow">No Play History</div>}
{data.rating ? <ChuniRating className="text-2xl text-right" rating={+data.rating * 100} /> : null}
{data.scoreMax ? <div className="ml-2 text-center flex-grow sm:flex-grow-0">
<span className="font-semibold">High Score: </span>{data.scoreMax.toLocaleString()}
</div> : null}
</div>
{badges.length ? <div className={`flex-grow lg:flex-grow-0 ml-auto mr-auto sm:ml-0 lg:ml-auto lg:mr-0 mt-2 flex gap-0.5 flex-wrap justify-center sm:justify-start ${data.playlog.length ? 'cursor-pointer' : ''}`} onClick={toggleExpanded}>
{badges}
</div> : null}
{data.playlog.length ? <Accordion selectedKeys={expanded[i]} onSelectionChange={k => setExpanded(e => ({ ...e, [i]: k as any }))}>
<AccordionItem key="1" title="Play History">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 5xl:grid-cols-6 6xl:grid-cols-8 gap-2">
{data.playlog.map(p => <ChuniPlaylogCard key={p.id} playlog={p} className="h-48" />)}
</div>
</AccordionItem>
</Accordion> : null
}
</div>)
})}
</div>);
};

View File

@ -3,6 +3,7 @@ import { getImageUrl } from '@/helpers/assets';
import { ChuniTrophy } from '@/components/chuni/trophy'; import { ChuniTrophy } from '@/components/chuni/trophy';
import { PickNullable } from '@/types/pick-nullable'; import { PickNullable } from '@/types/pick-nullable';
import { ChuniRating } from '@/components/chuni/rating'; import { ChuniRating } from '@/components/chuni/rating';
import { formatJst } from '@/helpers/format-jst';
export type Profile = PickNullable<Awaited<ReturnType<typeof getUserData>>, export type Profile = PickNullable<Awaited<ReturnType<typeof getUserData>>,
'trophyName' | 'trophyRareType' | 'nameplateImage' | 'nameplateName' | 'teamName' | 'characterId' | 'level' | 'userName' | 'overPowerRate' | 'overPowerPoint' | 'lastPlayDate' | 'playerRating' | 'highestRating'>; 'trophyName' | 'trophyRareType' | 'nameplateImage' | 'nameplateName' | 'teamName' | 'characterId' | 'level' | 'userName' | 'overPowerRate' | 'overPowerPoint' | 'lastPlayDate' | 'playerRating' | 'highestRating'>;
@ -47,13 +48,7 @@ export const ChuniNameplate = ({ className, profile }: ChuniNameplateProps) => {
</div> </div>
<div className="leading-none py-[1%] border-b border-gray-700 flex items-baseline"> <div className="leading-none py-[1%] border-b border-gray-700 flex items-baseline">
<span className="font-normal text-[13cqh]">Last Play Date:&nbsp;</span> <span className="font-normal text-[13cqh]">Last Play Date:&nbsp;</span>
<span className="text-[15cqh]">{profile.lastPlayDate && new Date(`${profile.lastPlayDate} +0900`).toLocaleTimeString(undefined, { <span className="text-[15cqh]">{profile.lastPlayDate && formatJst(profile.lastPlayDate)}</span>
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: '2-digit'
})}</span>
</div> </div>
<div className="leading-none py-[2%] flex items-baseline"> <div className="leading-none py-[2%] flex items-baseline">
<ChuniRating className="text-[12cqh] text-stroke-[0.75cqh]" rating={profile.playerRating}> <ChuniRating className="text-[12cqh] text-stroke-[0.75cqh]" rating={profile.playerRating}>

View File

@ -1,11 +1,12 @@
import { getPlaylog } from '@/actions/chuni/playlog'; import { getPlaylog } from '@/actions/chuni/playlog';
import { getJacketUrl } from '@/helpers/assets'; import { getJacketUrl } from '@/helpers/assets';
import Link from 'next/link'; import Link from 'next/link';
import { floorToDp } from '@/helpers/floor-dp';
import { ChuniRating } from '@/components/chuni/rating'; import { ChuniRating } from '@/components/chuni/rating';
import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge'; import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
import { ChuniLevelBadge } from '@/components/chuni/level-badge'; import { ChuniLevelBadge } from '@/components/chuni/level-badge';
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container'; import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
import { formatJst } from '@/helpers/format-jst';
import { Ticker } from '@/components/ticker';
export type ChuniPlaylogCardProps = { export type ChuniPlaylogCardProps = {
playlog: Awaited<ReturnType<typeof getPlaylog>>['data'][number], playlog: Awaited<ReturnType<typeof getPlaylog>>['data'][number],
@ -25,16 +26,22 @@ const getChangeColor = (val: number) => {
} }
export const ChuniPlaylogCard = ({ playlog, className }: ChuniPlaylogCardProps) => { export const ChuniPlaylogCard = ({ playlog, className }: ChuniPlaylogCardProps) => {
return (<div className={`rounded-md bg-content1 flex flex-col p-2 border border-black/25 ${className ?? ''}`}> return (<div className={`rounded-md bg-content1 relative flex flex-col p-2 pt-1 border border-black/25 ${className ?? ''}`}>
<div className="flex"> <div className="flex">
<ChuniDifficultyContainer difficulty={playlog.chartId ?? 0} className="mr-2 w-28 aspect-square flex-shrink-0 relative p-1"> <div className="flex-shrink-0 mr-2 mt-auto">
<ChuniDifficultyContainer difficulty={playlog.chartId ?? 0} className="w-28 aspect-square relative p-1">
<ChuniLevelBadge className="absolute -bottom-1.5 -right-1.5 w-12" music={playlog} /> <ChuniLevelBadge className="absolute -bottom-1.5 -right-1.5 w-12" music={playlog} />
<img className="aspect-square w-full rounded overflow-hidden" src={getJacketUrl(`chuni/jacket/${playlog.jacketPath}`)} <img className="aspect-square w-full rounded overflow-hidden" src={getJacketUrl(`chuni/jacket/${playlog.jacketPath}`)}
alt={playlog.title ?? ''} /> alt={playlog.title ?? ''} />
</ChuniDifficultyContainer> </ChuniDifficultyContainer>
<div className="flex flex-col leading-tight overflow-hidden text-nowrap"> </div>
<Link href={`/chuni/music/${playlog.songId}`} lang="ja" className="underline hover:text-secondary transition mb-2 font-semibold">{ playlog.title }</Link>
<span lang="ja" className="text-sm mb-2">{ playlog.artist }</span> <div className="flex flex-col leading-tight overflow-hidden text-nowrap w-full">
<div className="text-xs text-right -mb-0.5 w-full">{ formatJst(playlog.userPlayDate!) }</div>
<Link href={`/chuni/music/${playlog.songId}`} lang="ja" className="hover:text-secondary transition mb-2 font-semibold">
<Ticker hoverOnly noDelay><span className="underline">{ playlog.title }</span></Ticker>
</Link>
<Ticker className="text-sm mb-2">{ playlog.artist }</Ticker>
<span lang="ja" className="text-sm mb-2">{ playlog.genre }</span> <span lang="ja" className="text-sm mb-2">{ playlog.genre }</span>
<div className="text-sm flex items-center"> <div className="text-sm flex items-center">
Rating:&nbsp;<ChuniRating className="text-medium" rating={+playlog.rating * 100} /> Rating:&nbsp;<ChuniRating className="text-medium" rating={+playlog.rating * 100} />

View File

@ -19,6 +19,16 @@ const ChuniScoreBadgeVariant = {
type Variant = keyof typeof ChuniScoreBadgeVariant | (typeof ChuniScoreBadgeVariant)[keyof typeof ChuniScoreBadgeVariant]; type Variant = keyof typeof ChuniScoreBadgeVariant | (typeof ChuniScoreBadgeVariant)[keyof typeof ChuniScoreBadgeVariant];
const RANK_VARIANTS = [0, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4, 4] as const; const RANK_VARIANTS = [0, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4, 4] as const;
export const CHUNI_LAMP_DISPLAY = new Map<number, Variant>([
[1, 'gold'],
[2, 'gold'],
[3, 'gold'],
[4, 'platinum'],
[5, 'platinum'],
[6, 'platinum']
]);
export const getVariantFromRank = (rank: number): Variant => { export const getVariantFromRank = (rank: number): Variant => {
return RANK_VARIANTS[rank]; return RANK_VARIANTS[rank];
} }
@ -35,6 +45,10 @@ export const getVariantFromScore = (score: number): Variant => {
return 0; return 0;
}; };
export const getVariantFromLamp = (lamp: number): Variant => {
return CHUNI_LAMP_DISPLAY.get(lamp)!
};
export type ChuniScoreBadgeProps = { export type ChuniScoreBadgeProps = {
children: ReactNode, children: ReactNode,
variant: Variant, variant: Variant,

View File

@ -0,0 +1,102 @@
'use client';
import { Button, Card, CardBody, Slider } from '@nextui-org/react';
import { PauseCircleIcon, PlayCircleIcon } from '@heroicons/react/24/solid';
import { ReactNode, useEffect, useRef, useState } from 'react';
export type MusicPlayerProps = {
audio: string,
image: string,
children?: ReactNode,
className?: string
};
const formatTimestamp = (timestamp: number) => {
if (Number.isNaN(timestamp))
return '--:--';
return `${Math.floor(timestamp / 60).toFixed(0)}:${(timestamp % 60).toFixed(0).padStart(2, '0')}`;
};
export const MusicPlayer = ({ audio, image, children, className }: MusicPlayerProps) => {
const [duration, setDuration] = useState(NaN);
const [progress, setProgress] = useState(NaN);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (!Number.isNaN(audio.duration)) {
setDuration(audio.duration)
setProgress(0);
}
const metadata = () => {
if (audio.duration !== undefined)
setDuration(audio.duration);
setProgress(0);
};
audio.addEventListener('loadedmetadata', metadata);
const timeupdate = () => {
if (audio.currentTime !== undefined)
setProgress(audio.currentTime);
};
audio.addEventListener('timeupdate', timeupdate);
const ended = () => {
setPlaying(false);
}
audio.addEventListener('ended', ended);
return () => {
audio.removeEventListener('loadedmetadata', metadata);
audio.removeEventListener('timeupdate', timeupdate);
audio.removeEventListener('ended', ended);
audio.pause();
};
}, []);
useEffect(() => {
if (playing)
audioRef.current?.play();
else
audioRef.current?.pause();
}, [playing]);
const percent = (progress / duration) * 100;
return (<Card isBlurred radius="none" className={`border-none shadow-lg sm:rounded-2xl w-full max-w-full sm:max-w-[48rem] ${className ?? ''}`}>
<CardBody className="sm:rounded-2xl sm:p-4 bg-content1 sm:bg-content2">
<audio src={audio} ref={audioRef} />
<div className="grid grid-cols-12">
<div className="col-span-full sm:col-span-4 h-full flex items-center justify-center sm:justify-start">
<img src={image} alt="" className="aspect-square rounded-md shadow-2xl max-w-56 w-full border border-gray-500 sm:border-0" />
</div>
<div className="col-span-full sm:col-span-8 h-full flex flex-col pt-4 sm:pt-0 sm:pl-4 text-xl">
<div className="mb-2 sm:my-auto flex flex-col gap-1 items-center sm:items-start overflow-hidden">
{children}
</div>
<div className="mt-auto flex flex-col items-center">
<Slider className="cursor-pointer" size="sm" minValue={0} maxValue={100} step={0.0001}
value={Number.isNaN(percent) ? 0 : percent}
onChange={v => {
if (audioRef.current && !Array.isArray(v))
audioRef.current.currentTime = v / 100 * duration;
}}/>
<div className="flex text-medium w-full">
<span>{formatTimestamp(Math.min(progress, duration))}</span>
<Button isIconOnly radius="full" variant="light" size="lg" className="mx-auto mt-1" onClick={() => setPlaying(p => !p)}>
{playing ? <PauseCircleIcon /> : <PlayCircleIcon />}
</Button>
<span>{formatTimestamp(duration)}</span>
</div>
</div>
</div>
</div>
</CardBody>
</Card>)
};

View File

@ -0,0 +1,47 @@
@keyframes outer-overflow {
0% {
transform: translateX(0);
}
10% {
transform: translateX(0);
}
90% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes inner-overflow {
0% {
transform: translateX(0);
}
10% {
transform: translateX(0);
}
90% {
transform: translateX(-100%);
}
100% {
transform: translateX(-100%);
}
}
@keyframes outer-overflow-nodelay {
0% {
transform: translateX(0);
}
100% {
transform: translateX(100%);
}
}
@keyframes inner-overflow-nodelay {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}

23
src/components/ticker.tsx Normal file
View File

@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import './ticker.scss';
export type TickerProps = {
children?: ReactNode,
hoverOnly?: boolean,
className?: string,
noDelay?: boolean
}
export const Ticker = ({ children, hoverOnly, className, noDelay }: TickerProps) => {
const hoverClass = hoverOnly ? '[&:hover_*]:[animation-play-state:running] [&_*]:[animation-play-state:paused]' : '[&:hover_*]:[animation-play-state:paused]';
const outerAnimation = noDelay ? 'animate-[outer-overflow-nodelay_15s_linear_infinite_alternate]' : 'animate-[outer-overflow_15s_linear_infinite_alternate]';
const innerAnimation = noDelay ? 'animate-[inner-overflow-nodelay_15s_linear_infinite_alternate]' : 'animate-[inner-overflow_15s_linear_infinite_alternate]';
return (<div className={`text-nowrap whitespace-nowrap overflow-hidden w-full ${hoverClass} ${className ?? ''}`}>
<div className={`${outerAnimation} ticker max-w-full inline-block`}>
<div className={`${innerAnimation} ticker inline-block`}>
{ children }
</div>
</div>
</div>)
};

View File

@ -0,0 +1,8 @@
export const CHUNI_DIFFICULTIES = [
'Basic',
'Advanced',
'Expert',
'Master',
'Ultima',
'World\'s End'
];

View File

@ -0,0 +1,9 @@
// TODO: check if these are correct
export const CHUNI_LAMPS = new Map([
[1, 'Clear'],
[2, 'Hard'],
[3, 'Absolute'],
[4, 'Absolute+'],
[5, 'Absolute++'],
[6, 'Catastrophy']
]);

View File

@ -0,0 +1 @@
export const CHUNI_SCORE_RANKS = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'];

11
src/helpers/format-jst.ts Normal file
View File

@ -0,0 +1,11 @@
const DEFAULT_OPTIONS = {
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: '2-digit'
} as const;
export const formatJst = (time: string, options: Intl.DateTimeFormatOptions = DEFAULT_OPTIONS) => new Date(`${time} +0900`)
.toLocaleTimeString(undefined, DEFAULT_OPTIONS);