최신 AI 반영하는 동시에 비주얼적으로는 요즘 트렌드인 아날로그 턴테이블 감성을 녹여내고 싶어 진행한 프로젝트
인터렉션 구현은 Framer Motion을 사용하여 애니메이션 기능을 학습하는 것이 목표
디자인
애플의 WWDC25 참고
- 디자인 순서
- 구조 → 내비게이션 → 콘텐츠 → 비주얼 디자인
구조
- 명확성
- 어디에 있는지 → 상단 타이틀
- 무엇을 할 수있는지 → 검색 버튼, 로그아웃 버튼
- 어디로 갈 수 있는지 → 하단 탭바
- 정보 구조 설계 : 서비스의 모든 기능 정리
- 로그인, 회원가입
- 메인 (첫 진입 화면)
- 플레이리스트
- Track 삭제
- 플레이
- 검색
- Track 추가
내비게이션
- 다른 섹션 탐색
- 플레이리스트, 검색
- 2개 ~ 최대 4개 동일한 너비
- input 키보드로 일시적으로 가려질 수 있는 영역
콘텐츠
- 가장 중요하게 생각하는 것, 기대하는 것 안내
- 정돈된 리스트 정렬 방법
- 시간 : 최근 파일, 계절성 → 여름에 듣기 좋은 레코드
- 진행 상태 : 임시 저장, 이어서 보기
- 패턴 : 연관 상품 → 장르별 레코드
비주얼 디자인
- 시각적 위계
- 텍스트 가독성 : 앨범커버에 이미지 그라데이션 블러 추가
구현
- 플레이리스트 데이터 스키마 설계 및 데이터 구축
- iTunes API로 앨범 정보 가져오기
- 곡명, 아티스트명, 앨범 커버 이미지
- YouTube API로 유튜브 검색 → id 가져오기
- 검색어를 아이튠즈에서 특정한 ‘곡명 + 아티스트명 + official’ 조합으로 가져오기
- 데이터 저장, 패치 : Supabase
- 인터렉션 기능 구현 : Framer Motion
로그인, 회원가입
이미지 색상추출 node-vibrant
- 이미지 블러로 백그라운드 처리를 하면 성능 부하가 심해져 패드나 모바일 디바이스에서 끊겨 앨범커버 이미지 색상 추가
- 플레이리스트 추가 시 앨범 커버 색상 값 같이 저장
Browser 빌드
imgcanvas로 로드해서 픽셀로 색상 추출
import { Vibrant } from 'node-vibrant/browser';
const palette = await Vibrant.from(url).getPalette();Node 빌드
- DOM이 없어 별도의 디코딩 라이브러리로 색상 추출
- 서버가 직접 fetch하기 때문에 CORS 무관
src/app/api/palette/route.ts
import { NextResponse } from 'next/server';
import { Vibrant } from 'node-vibrant/node';
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get('url');
if (!url) return NextResponse.json({ error: 'URL Error' }, { status: 400 });
try {
const palette = await Vibrant.from(url).getPalette();
// 메서드(palette Swatch) JSON 변환되면 사라지기 때문에 필요한 hex값만 추출
return NextResponse.json({
vibrant: palette.Vibrant?.hex,
muted: palette.Muted?.hex,
darkVibrant: palette.DarkVibrant?.hex,
lightVibrant: palette.LightVibrant?.hex,
darkMuted: palette.DarkMuted?.hex,
lightMuted: palette.LightMuted?.hex,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}사용 예시
src/app/hooks/useTrack.ts
return useMutation({
mutationFn: async ({ userId, track }: { userId: string; track: Itunes }) => {
// Browser 빌드 시
const palette = await Vibrant.from(track.artworkUrl100).getPalette();
return trackService.addTrackToPlaylist({
userId,
newTrack:{
...
palette: {
vibrant: palette.Vibrant?.hex,
muted: palette.Muted?.hex,
darkVibrant: palette.DarkVibrant?.hex,
lightVibrant: palette.LightVibrant?.hex,
darkMuted: palette.DarkMuted?.hex,
lightMuted: palette.LightMuted?.hex,
},
}
})
// Node 빌드 시
const paletteRes = await fetch(`/api/palette?url=${encodeURIComponent(track.artworkUrl100)}`);
if(!paletteRes.ok) throw new Error('vibrant error');
const palette = await paletteRes.json();
return trackService.addTrackToPlaylist({
userId,
newTrack: {
...
palette
}
})
}
})export default function Componenet({track}: Props){
style={{
background: `linear-gradient(to right, ${track.palette?.darkVibrant}, ${track.palette?.darkMuted})`,
}}
}PlayList Framer Motion
앨범커버 리스트
- 드래그 Y축 고정
onClick대신onTap사용하여 드래그와 클릭 시(탭) motion 구분useCallback사용하여y, listHeight변경시만 리렌더링
const [maxDrag, setMaxDrag] = useState(0);
const { ref, width } = useMeasure();
const listHeight = Math.round(width * 0.5);
const y = useMotionValue(0);
const selectedItem = useCallback(
(index: number) => () => {
animate(y, listHeight * index, {
type: 'spring',
stiffness: 200,
damping: 20,
mass: 0.8,
});
},
[y, listHeight],
);
useEffect(() => {
if (!tracks || tracks.length === 0) return;
setMaxDrag((tracks.length - 1) * listHeight);
if (maxDrag < y.get()) {
y.set(maxDrag);
}
}, [tracks, listHeight]);
...
<motion.div
drag="y"
className="absolute inset-0 m-auto flex max-w-md cursor-grab flex-col-reverse"
style={{ y }}
dragConstraints={{ top: 0, bottom: maxDrag }}
dragElastic={0.2}
>
{tracks.map((track, index) => (
<AlbumCover
key={track.id}
track={track}
totalTracks={tracks.length}
index={index}
y={y}
onTap={selectedItem(index)} // 중앙에 위치한 앨범 커버
listHeight={listHeight}
/>
))}
</motion.div>
...앨범커버 Motion
useMotionValueEvent(motionValue, 이벤트, 콜백)Motion Values
export default function AlbumCover({ track, totalTracks, index, y, onTap, listHeight }: Props) {
const targetScrollY = index * listHeight;
const inputValues = [
targetScrollY + listHeight * 2,
targetScrollY + listHeight,
targetScrollY, // 중앙에 위치한 앨범 커버
targetScrollY - listHeight * 2,
targetScrollY - listHeight * 3,
targetScrollY - listHeight * 4,
];
// 변환 값 : useTransform(y, [y 상태 값], [y 맵핑 후 변환 값])
const scale = useTransform(y, inputValues, [0.7, 0.8, 1, 0.8, 0.7, 0.6]);
// const zIndex = useTransform(y, inputValues.slice(0, 4), [trackIndex, trackIndex, 100, trackIndex]);
// 되도록 slice 사용 지양 (맵핑 갯수 동일하게 사용 : 실수 방지)
const zIndex = useTransform(y, inputValues, [
trackIndex,
trackIndex,
100,
trackIndex,
trackIndex,
trackIndex,
]);
const transformedX = useTransform(y, inputValues, [-40, -20, 50, 150, 200, 250]);
const x = useMotionValue(transformedX.get());
const indexWeight = useTransform(y, inputValues, [1, 2, 3, 4, 5, 6]);
// 선택한 앨범 커버
const isActiveTrack = activeTrack?.id === track?.id;
// transformedX 값(y) 변경 시 마다 실행
useMotionValueEvent(transformedX, 'change', latest => {
if (!activeTrack) {
x.set(latest);
}
});
useEffect(() => {
const currentWeight = indexWeight.get();
if (!activeTrack) {
animate(x, transformedX.get(), {
type: 'spring',
stiffness: 100 + currentWeight * 10,
damping: 24 - currentWeight,
});
} else if (!isActiveTrack) {
animate(x, -400, {
type: 'spring',
stiffness: currentWeight * 40,
damping: 22 - currentWeight,
});
}
}, [activeTrack, isActiveTrack]);
...
<motion.div
className="shrink-0"
onTap={onTap}
style={{
scale,
zIndex,
x,
}}
>
<motion.div
layoutId={`album-${track.id}`}
layout
style={{
width: '50%',
height: 0,
paddingTop: '50%',
background: `linear-gradient(to right, ${track.palette?.vibrant}, ${track.palette?.darkVibrant})`,
}}
>
...
}턴테이블 핀
- Framer Motion onPan 사용
PanInfo사용하여 현재 포인트 좌표(current) 값 가져오기- 각도 계산
Math.atan2(y, x)0 ~ PI 값 →180 / Math.PIdeg 값으로 변환
Math.floor(
// atan2(현재 포인트 좌표(current) - 기준 축(pivot))
(Math.atan2(currentY - pivotY, currentX - pivotX) * 180) / Math.PI,
);const pinWrapper = useRef<HTMLDivElement>(null);
const rotateRaw = useMotionValue(0);
const rotate = useSpring(rotateRaw, { stiffness: 500, damping: 30 });
const startAngle = useRef(0);
const pivotX = useRef(0);
const pivotY = useRef(0);
const getAngle = (x: number, y: number) =>
Math.floor((Math.atan2(y - pivotY.current, x - pivotX.current) * 180) / Math.PI);
const handlePinStart = (event: PointerEvent, info: PanInfo) => {
if (!pinWrapper.current) return;
const rect = pinWrapper.current.getBoundingClientRect();
pivotX.current = rect.left + rect.width / 2;
pivotY.current = rect.top;
startAngle.current = getAngle(info.point.x, info.point.y) - rotateRaw.get();
};
const handlePin = (event: PointerEvent, info: PanInfo) => {
if (!pinWrapper.current) return;
let deg = getAngle(info.point.x, info.point.y) - startAngle.current;
deg = Math.max(0, Math.min(30, deg));
rotateRaw.set(deg);
};앨범커버 listHeight 반응형
ResizeObserver로 리스트의 width 사이즈 비례하여 조정
- 새로운 Hook 생성 (useMeasure.ts)
- DOM 요소(ref) 생성 후 width 사이즈 가져오는 함수 실행
ref연결 방법- 객체 연결 :
useRef렌더링 끝난 후 { current }에 연결 (조건부 없음) - 콜백 함수 연결 : 렌더링 될 때마다 실행 방지 위해
useCallback사용
- 객체 연결 :
src/hooks/useMeasure.ts
import { useCallback, useRef, useState } from 'react';
interface Dimensions {
width: number;
height: number;
// top: number;
// left: number;
// x: number;
// y: number;
}
export function useMeasure<T extends HTMLDivElement>() {
const [dimensions, setDimensions] = useState<Dimensions>({
width: 0,
height: 0,
// top: 0,
// left: 0,
// x: 0,
// y: 0,
});
const observerRef = useRef<ResizeObserver | null>(null);
const ref = useCallback((node: T | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
}
if (node !== null) {
const resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
setDimensions({ width: entry.contentRect.width, height: entry.contentRect.height });
});
});
resizeObserver.observe(node);
observerRef.current = resizeObserver;
}
return () => {
if (observerRef.current) {
console.log('observe 해제');
observerRef.current.disconnect();
}
};
}, []);
return { ref, ...dimensions };
}