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.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.artist }
+
+
+
+
+
+
+
+
+
{ 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 (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
{
+ if (audioRef.current && !Array.isArray(v))
+ audioRef.current.currentTime = v / 100 * duration;
+ }}/>
+
+
{formatTimestamp(Math.min(progress, duration))}
+
+
{formatTimestamp(duration)}
+
+
+
+
+
+ )
+};
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 ()
+};
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);
+