From e43f0f680d0e5ca4045c1e25311bea163f9965af Mon Sep 17 00:00:00 2001 From: sk1982 Date: Thu, 14 Mar 2024 17:24:02 -0400 Subject: [PATCH] chuni: add music detail page --- .idea/watcherTasks.xml | 4 + src/actions/chuni/playlog.ts | 10 +- .../chuni/music/[musicId]/page.tsx | 36 +++++++ src/components/chuni/music-list.tsx | 65 ++++------- src/components/chuni/music-playlog.tsx | 89 +++++++++++++++ src/components/chuni/nameplate.tsx | 9 +- src/components/chuni/playlog-card.tsx | 27 +++-- src/components/chuni/score-badge.tsx | 14 +++ src/components/music-player.tsx | 102 ++++++++++++++++++ src/components/ticker.scss | 47 ++++++++ src/components/ticker.tsx | 23 ++++ src/helpers/chuni/difficulties.ts | 8 ++ src/helpers/chuni/lamps.ts | 9 ++ src/helpers/chuni/score-ranks.ts | 1 + src/helpers/format-jst.ts | 11 ++ 15 files changed, 390 insertions(+), 65 deletions(-) create mode 100644 .idea/watcherTasks.xml create mode 100644 src/app/(with-header)/chuni/music/[musicId]/page.tsx create mode 100644 src/components/chuni/music-playlog.tsx create mode 100644 src/components/music-player.tsx create mode 100644 src/components/ticker.scss create mode 100644 src/components/ticker.tsx create mode 100644 src/helpers/chuni/difficulties.ts create mode 100644 src/helpers/chuni/lamps.ts create mode 100644 src/helpers/chuni/score-ranks.ts create mode 100644 src/helpers/format-jst.ts diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml new file mode 100644 index 0000000..fb0d65a --- /dev/null +++ b/.idea/watcherTasks.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/actions/chuni/playlog.ts b/src/actions/chuni/playlog.ts index fa371bf..78c9fba 100644 --- a/src/actions/chuni/playlog.ts +++ b/src/actions/chuni/playlog.ts @@ -14,6 +14,8 @@ export type GetPlaylogOptions = { export async function getPlaylog(opts: GetPlaylogOptions) { 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 .selectFrom('chuni_score_playlog as playlog') @@ -41,8 +43,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) { ) .selectFrom('p') .where(({ and, eb }) => and([ - ...('musicId' in opts ? [eb('p.songId', '=', opts.musicId)] : []), - ...('chartId' in opts ? [eb('p.chartId', '=', opts.chartId)] : []), + ...(!Number.isNaN(musicId) ? [eb('p.songId', '=', musicId)] : []), + ...(!Number.isNaN(chartId) ? [eb('p.chartId', '=', chartId)] : []), ])) .selectAll() .limit(+opts.limit) @@ -54,8 +56,8 @@ export async function getPlaylog(opts: GetPlaylogOptions) { .where(({ and, eb }) => and([ eb('playlog.user', '=', user.id), eb('playlog.id', '<', playlog.at(-1)!.id), - ...('musicId' in opts ? [eb('playlog.musicId', '=', opts.musicId)] : []), - ...('chartId' in opts ? [eb('playlog.level', '=', opts.chartId)] : []), + ...(!Number.isNaN(musicId) ? [eb('playlog.musicId', '=', musicId)] : []), + ...(!Number.isNaN(chartId) ? [eb('playlog.level', '=', chartId)] : []), ])) .select(({ fn }) => fn.countAll().as('remaining')) .executeTakeFirstOrThrow()).remaining); diff --git a/src/app/(with-header)/chuni/music/[musicId]/page.tsx b/src/app/(with-header)/chuni/music/[musicId]/page.tsx new file mode 100644 index 0000000..b6919d4 --- /dev/null +++ b/src/app/(with-header)/chuni/music/[musicId]/page.tsx @@ -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 (
+ + { music[0].title } + { music[0].artist } + { music[0].genre } + + +
); +} diff --git a/src/components/chuni/music-list.tsx b/src/components/chuni/music-list.tsx index 9c03226..9591af8 100644 --- a/src/components/chuni/music-list.tsx +++ b/src/components/chuni/music-list.tsx @@ -9,10 +9,14 @@ import { worldsEndStars } from '@/helpers/chuni/worlds-end-stars'; import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container'; import { getJacketUrl } from '@/helpers/assets'; 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 Link from 'next/link'; 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) => { 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); }; -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' }) => { let itemWidth = 0; let itemHeight = 0; @@ -81,12 +66,12 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) if (size === 'sm') { itemWidth = 175; - itemHeight = 225; - itemClass = 'w-[175px] h-[230px] py-1.5 px-1'; + itemHeight = 235; + itemClass = 'w-[175px] h-[235px] py-1.5 px-1'; } else { itemWidth = 285; - itemHeight = 360; - itemClass = 'w-[285px] h-[360px] py-1.5 px-1'; + itemHeight = 375; + itemClass = 'w-[285px] h-[375px] py-1.5 px-1'; } const listRef = useRef(null); @@ -106,7 +91,7 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' }) onScroll={onChildScroll} scrollTop={scrollTop} ref={listRef} rowRenderer={({ index, key, style }) =>
{music.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map(item =>
- +
{item.title {item.rating && !item.worldsEndTag &&
@@ -118,8 +103,8 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
{size === 'lg' &&
- {item.isSuccess ? - {LAMPS.get(item.isSuccess)} + {item.isSuccess ? + {CHUNI_LAMPS.get(item.isSuccess)} : null}
} @@ -136,13 +121,10 @@ const MusicGrid = ({ music, size }: ChuniMusicListProps & { size: 'sm' | 'lg' })
- {item.title} + 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`}> + {item.title} -
- {item.artist} -
+ {item.artist}
)}
} />) @@ -167,14 +149,9 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { value: new Set(), className: 'col-span-6 md:col-span-3 5xl:col-span-1', props: { - children: [ - Basic, - Advanced, - Expert, - Master, - Ultima, - World's End, - ], + children: CHUNI_DIFFICULTIES.map((name, i) => + {name} + ), selectionMode: 'multiple' }, filter: (val: Set, data) => !val.size || val.has(data.chartId?.toString()!) @@ -200,14 +177,14 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { children: [ All Justice, Full Combo, - ...[...LAMPS].map(([id, name]) => {name}) + ...[...CHUNI_LAMPS].map(([id, name]) => {name}) ], selectionMode: 'multiple' }, filter: (val: Set, data) => { 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()))) return false @@ -238,7 +215,7 @@ export const ChuniMusicList = ({ music }: ChuniMusicListProps) => { value: new Set(), 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: { - children: SCORES + children: CHUNI_SCORE_RANKS .map((s, i) => {s}) .reverse(), selectionMode: 'multiple' diff --git a/src/components/chuni/music-playlog.tsx b/src/components/chuni/music-playlog.tsx new file mode 100644 index 0000000..6893a8d --- /dev/null +++ b/src/components/chuni/music-playlog.tsx @@ -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>, + playlog: Awaited> +}; + +export const ChuniMusicPlaylog = ({ music, playlog }: ChuniMusicPlaylogProps) => { + type Music = (typeof music)[number]; + type Playlog = (typeof playlog)['data'][number]; + const defaultExpanded: Record> = {} + + 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 (
+ {difficulties.map((data, i) => { + const badges = [ + !!data.scoreRank && + {CHUNI_SCORE_RANKS[data.scoreRank]} + , + !!data.isSuccess && + {CHUNI_LAMPS.get(data.isSuccess)} + , + !!data.isFullCombo && !data.isAllJustice && + Full Combo + , + !!data.isAllJustice && + All Justice + , + ].filter(x => x); + + const toggleExpanded = () => expanded[i] && setExpanded(e => + ({ ...e, + [i]: e[i].size ? new Set() : new Set(['1']) + })); + + return (
+
+
+
+ +
+
{CHUNI_DIFFICULTIES[i]}
+
+ {!data.playlog.length &&
No Play History
} + {data.rating ? : null} + {data.scoreMax ?
+ High Score: {data.scoreMax.toLocaleString()} +
: null} +
+ {badges.length ?
+ {badges} +
: null} + {data.playlog.length ? setExpanded(e => ({ ...e, [i]: k as any }))}> + +
+ {data.playlog.map(p => )} +
+
+
: null + } +
) + })} +
); +}; diff --git a/src/components/chuni/nameplate.tsx b/src/components/chuni/nameplate.tsx index 26490c6..4435633 100644 --- a/src/components/chuni/nameplate.tsx +++ b/src/components/chuni/nameplate.tsx @@ -3,6 +3,7 @@ import { getImageUrl } from '@/helpers/assets'; import { ChuniTrophy } from '@/components/chuni/trophy'; import { PickNullable } from '@/types/pick-nullable'; import { ChuniRating } from '@/components/chuni/rating'; +import { formatJst } from '@/helpers/format-jst'; export type Profile = PickNullable>, 'trophyName' | 'trophyRareType' | 'nameplateImage' | 'nameplateName' | 'teamName' | 'characterId' | 'level' | 'userName' | 'overPowerRate' | 'overPowerPoint' | 'lastPlayDate' | 'playerRating' | 'highestRating'>; @@ -47,13 +48,7 @@ export const ChuniNameplate = ({ className, profile }: ChuniNameplateProps) => {
Last Play Date:  - {profile.lastPlayDate && new Date(`${profile.lastPlayDate} +0900`).toLocaleTimeString(undefined, { - month: 'numeric', - day: 'numeric', - year: '2-digit', - hour: 'numeric', - minute: '2-digit' - })} + {profile.lastPlayDate && formatJst(profile.lastPlayDate)}
diff --git a/src/components/chuni/playlog-card.tsx b/src/components/chuni/playlog-card.tsx index 3e1137c..dfa3181 100644 --- a/src/components/chuni/playlog-card.tsx +++ b/src/components/chuni/playlog-card.tsx @@ -1,11 +1,12 @@ import { getPlaylog } from '@/actions/chuni/playlog'; import { getJacketUrl } from '@/helpers/assets'; import Link from 'next/link'; -import { floorToDp } from '@/helpers/floor-dp'; import { ChuniRating } from '@/components/chuni/rating'; import { ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge'; import { ChuniLevelBadge } from '@/components/chuni/level-badge'; import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container'; +import { formatJst } from '@/helpers/format-jst'; +import { Ticker } from '@/components/ticker'; export type ChuniPlaylogCardProps = { playlog: Awaited>['data'][number], @@ -25,16 +26,22 @@ const getChangeColor = (val: number) => { } export const ChuniPlaylogCard = ({ playlog, className }: ChuniPlaylogCardProps) => { - return (
+ return (
- - - {playlog.title - -
- { playlog.title } - { playlog.artist } +
+ + + {playlog.title + +
+ +
+
{ formatJst(playlog.userPlayDate!) }
+ + { playlog.title } + + { playlog.artist } { playlog.genre }
Rating:  diff --git a/src/components/chuni/score-badge.tsx b/src/components/chuni/score-badge.tsx index 70940e1..e9e2331 100644 --- a/src/components/chuni/score-badge.tsx +++ b/src/components/chuni/score-badge.tsx @@ -19,6 +19,16 @@ const 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; + +export const CHUNI_LAMP_DISPLAY = new Map([ + [1, 'gold'], + [2, 'gold'], + [3, 'gold'], + [4, 'platinum'], + [5, 'platinum'], + [6, 'platinum'] +]); + export const getVariantFromRank = (rank: number): Variant => { return RANK_VARIANTS[rank]; } @@ -35,6 +45,10 @@ export const getVariantFromScore = (score: number): Variant => { return 0; }; +export const getVariantFromLamp = (lamp: number): Variant => { + return CHUNI_LAMP_DISPLAY.get(lamp)! +}; + export type ChuniScoreBadgeProps = { children: ReactNode, variant: Variant, diff --git a/src/components/music-player.tsx b/src/components/music-player.tsx new file mode 100644 index 0000000..d2afbaa --- /dev/null +++ b/src/components/music-player.tsx @@ -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(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 ( + + + ) +}; diff --git a/src/components/ticker.scss b/src/components/ticker.scss new file mode 100644 index 0000000..5742d06 --- /dev/null +++ b/src/components/ticker.scss @@ -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%); + } +} diff --git a/src/components/ticker.tsx b/src/components/ticker.tsx new file mode 100644 index 0000000..0fa7f53 --- /dev/null +++ b/src/components/ticker.tsx @@ -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 (
+
+
+ { children } +
+
+
) +}; diff --git a/src/helpers/chuni/difficulties.ts b/src/helpers/chuni/difficulties.ts new file mode 100644 index 0000000..24f47f0 --- /dev/null +++ b/src/helpers/chuni/difficulties.ts @@ -0,0 +1,8 @@ +export const CHUNI_DIFFICULTIES = [ + 'Basic', + 'Advanced', + 'Expert', + 'Master', + 'Ultima', + 'World\'s End' +]; diff --git a/src/helpers/chuni/lamps.ts b/src/helpers/chuni/lamps.ts new file mode 100644 index 0000000..94e32bd --- /dev/null +++ b/src/helpers/chuni/lamps.ts @@ -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'] +]); diff --git a/src/helpers/chuni/score-ranks.ts b/src/helpers/chuni/score-ranks.ts new file mode 100644 index 0000000..5bf8609 --- /dev/null +++ b/src/helpers/chuni/score-ranks.ts @@ -0,0 +1 @@ +export const CHUNI_SCORE_RANKS = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+']; diff --git a/src/helpers/format-jst.ts b/src/helpers/format-jst.ts new file mode 100644 index 0000000..b25a22c --- /dev/null +++ b/src/helpers/format-jst.ts @@ -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); +