diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 31bdf30..c223463 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,6 +1,6 @@
import { Feather } from '@expo/vector-icons';
import { Tabs, router } from 'expo-router';
-import { Alert, Pressable, View } from 'react-native';
+import { Alert, Platform, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '@/constants/colors';
@@ -9,6 +9,11 @@ import { useTravelSessionStore } from '@/store/travelSessionStore';
type TabIconName = keyof typeof Feather.glyphMap;
+const webGlassTabBarStyle = {
+ backdropFilter: 'blur(22px) saturate(150%)',
+ WebkitBackdropFilter: 'blur(22px) saturate(150%)',
+};
+
function TabIcon({ name, color }: { name: TabIconName; color: string }) {
return ;
}
@@ -38,10 +43,12 @@ function CameraTabButton() {
-
+
+
+
);
}
@@ -53,8 +60,8 @@ export default function TabsLayout() {
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index e256bcd..188644b 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,5 +1,5 @@
import { router } from 'expo-router';
-import { useCallback, useEffect } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { ScrollView } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -11,13 +11,17 @@ import {
import { useNearbyPlacesQuery } from '@/api/tourQueries';
import { MiniPlayer } from '@/components/MiniPlayer';
import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection';
-import { HomeHeader, isHomeTopFilter } from '@/components/home/HomeHeader';
-import { LocationContextCard } from '@/components/home/LocationContextCard';
+import {
+ HomeHeader,
+ HomeTopFilterBar,
+ isHomeTopFilter,
+} from '@/components/home/HomeHeader';
import {
MoodRecommendationSection,
isMoodRecommendationFilter,
} from '@/components/home/MoodRecommendationSection';
import { MusicLogSection } from '@/components/home/MusicLogSection';
+import { TravelModeSuggestionSheet } from '@/components/home/TravelModeSuggestionSheet';
import { TravelSessionCard } from '@/components/home/TravelSessionCard';
import { Screen } from '@/components/Screen';
import { getHomeContentBottomPadding } from '@/constants/layout';
@@ -36,6 +40,8 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon
function HomeContent() {
const insets = useSafeAreaInsets();
+ const [dismissedSuggestionPlaceId, setDismissedSuggestionPlaceId] =
+ useState();
const {
selectedMoodFilter,
selectedTopFilter,
@@ -53,6 +59,7 @@ function HomeContent() {
currentPlace,
locationStatus,
locationUpdatedAt,
+ recommendationMode,
selectedMode,
session,
endSession,
@@ -60,6 +67,7 @@ function HomeContent() {
setLocationStatus,
setMode,
setPlace,
+ setRecommendationMode,
startSession,
} = useTravelSessionStore();
@@ -72,11 +80,14 @@ function HomeContent() {
location: currentLocation,
locationRecommendationEnabled: profile.locationRecommendationEnabled,
place: currentPlace,
+ recommendationMode,
});
const moodRecommendationsQuery = useMoodRecommendationsQuery({
+ currentPlace,
moodFilter: selectedMoodFilter,
preferredGenres: profile.preferredGenres,
preferredMoods: profile.preferredMoods,
+ recommendationMode,
topFilter: selectedTopFilter,
travelStyles: profile.travelStyles,
});
@@ -152,6 +163,21 @@ function HomeContent() {
},
[addRecommendationEvent, selectedMoodFilter, setSelectedMoodFilter],
);
+ const handleSelectRecommendationMode = useCallback(
+ (mode: typeof recommendationMode) => {
+ if (mode === recommendationMode) {
+ return;
+ }
+
+ setRecommendationMode(mode);
+ addRecommendationEvent({
+ context: createRecommendationEventContext({ recommendationMode: mode }),
+ type: 'recommendation_mode_change',
+ value: mode,
+ });
+ },
+ [addRecommendationEvent, recommendationMode, setRecommendationMode],
+ );
const handleEnableLocationRecommendation = () => {
updateProfile({
companionType: profile.companionType,
@@ -181,6 +207,23 @@ function HomeContent() {
setLocationStatus('unavailable');
}
}, [locationStatus, setLocation, setLocationStatus]);
+ const handleSetCurrentLocation = useCallback(() => {
+ if (!profile.locationRecommendationEnabled) {
+ handleEnableLocationRecommendation();
+ }
+
+ void handleRefreshLocation();
+ }, [
+ handleEnableLocationRecommendation,
+ handleRefreshLocation,
+ profile.locationRecommendationEnabled,
+ ]);
+
+ const shouldShowTravelModeSuggestion =
+ Boolean(currentPlace) &&
+ profile.locationRecommendationEnabled &&
+ recommendationMode === 'everyday' &&
+ dismissedSuggestionPlaceId !== currentPlace?.id;
return (
@@ -198,26 +241,11 @@ function HomeContent() {
showsVerticalScrollIndicator={false}
>
-
-
+
+
{currentTrack ? : null}
+ {shouldShowTravelModeSuggestion && currentPlace ? (
+ setDismissedSuggestionPlaceId(currentPlace.id)}
+ onStartTravelMode={() => {
+ handleSelectRecommendationMode('travel');
+ setDismissedSuggestionPlaceId(currentPlace.id);
+ }}
+ place={currentPlace}
+ />
+ ) : null}
);
}
diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx
index ce3ad5a..6c60ea1 100644
--- a/app/(tabs)/my.tsx
+++ b/app/(tabs)/my.tsx
@@ -1,15 +1,20 @@
import { Feather } from '@expo/vector-icons';
import { router } from 'expo-router';
+import { useCallback, useEffect } from 'react';
import { Pressable, ScrollView, View } from 'react-native';
+import { useNearbyPlacesQuery } from '@/api/tourQueries';
import { AppText } from '@/components/AppText';
+import { LocationContextCard } from '@/components/home/LocationContextCard';
import { MusicPlatformSettingsCard } from '@/components/my/MusicPlatformSettingsCard';
import { PermissionSettingsCard } from '@/components/my/PermissionSettingsCard';
import { Screen } from '@/components/Screen';
import { useNativePermissionSettings } from '@/hooks/useNativePermissionSettings';
import { useMusicPlatformStore } from '@/store/musicPlatformStore';
import { useRecommendationEventStore } from '@/store/recommendationEventStore';
+import { useTravelSessionStore } from '@/store/travelSessionStore';
import { useUserProfileStore } from '@/store/userProfileStore';
+import { requestForegroundLocationWithStatus } from '@/utils/location';
type MyMenuItem = {
description?: string;
@@ -33,16 +38,73 @@ function formatEventTime(value?: string) {
}
export default function MyScreen() {
- const { profile, resetOnboarding } = useUserProfileStore();
+ const { profile, resetOnboarding, updateProfile } = useUserProfileStore();
const { clearEvents, events, isHydrated } = useRecommendationEventStore();
const permissionSettings = useNativePermissionSettings();
const { selectedPlatformId, setSelectedPlatform } = useMusicPlatformStore();
+ const {
+ currentLocation,
+ currentPlace,
+ locationStatus,
+ locationUpdatedAt,
+ setLocation,
+ setLocationStatus,
+ setPlace,
+ } = useTravelSessionStore();
+ const nearbyPlacesQuery = useNearbyPlacesQuery({
+ enabled: profile.locationRecommendationEnabled,
+ location: currentLocation,
+ radiusMeters: 2000,
+ });
const selectedSummary = [
...profile.preferredGenres.slice(0, 2),
...profile.preferredMoods.slice(0, 1),
...profile.travelStyles.slice(0, 1),
].join(' · ');
+ useEffect(() => {
+ if (!nearbyPlacesQuery.data) {
+ return;
+ }
+
+ const nextPlace = nearbyPlacesQuery.data[0];
+
+ if (nextPlace?.id !== currentPlace?.id) {
+ setPlace(nextPlace);
+ }
+ }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]);
+
+ const handleEnableLocationRecommendation = useCallback(() => {
+ updateProfile({
+ companionType: profile.companionType,
+ locationRecommendationEnabled: true,
+ preferredGenres: profile.preferredGenres,
+ preferredMoods: profile.preferredMoods,
+ travelStyles: profile.travelStyles,
+ });
+ }, [profile, updateProfile]);
+
+ const handleRefreshLocation = useCallback(async () => {
+ if (locationStatus === 'loading') {
+ return;
+ }
+
+ setLocationStatus('loading');
+
+ try {
+ const result = await requestForegroundLocationWithStatus();
+
+ if (result.location) {
+ setLocation(result.location);
+ return;
+ }
+
+ setLocationStatus(result.status === 'denied' ? 'denied' : 'unavailable');
+ } catch {
+ setLocationStatus('unavailable');
+ }
+ }, [locationStatus, setLocation, setLocationStatus]);
+
const menuItems: MyMenuItem[] = [
{
description: selectedSummary || '아직 저장된 취향 정보가 없어요.',
@@ -100,6 +162,26 @@ export default function MyScreen() {
onRequest={permissionSettings.requestPermission}
/>
+
+
+
+
void;
};
-export function Chip({ label, onPress, selected = false }: ChipProps) {
+export function Chip({ label, onPress, selected = false, size = 'default' }: ChipProps) {
+ const isSmall = size === 'small';
+
return (
- {label}
+
+ {label}
+
);
}
diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx
index 5048e10..ec49a27 100644
--- a/src/components/MiniPlayer.tsx
+++ b/src/components/MiniPlayer.tsx
@@ -1,6 +1,8 @@
import { Feather } from '@expo/vector-icons';
+import { Image } from 'expo-image';
+import { LinearGradient } from 'expo-linear-gradient';
import { useState } from 'react';
-import { Pressable, View } from 'react-native';
+import { Modal, Platform, Pressable, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppText } from '@/components/AppText';
@@ -10,13 +12,28 @@ import { useLibraryStore } from '@/store/libraryStore';
import { usePlayerStore } from '@/store/playerStore';
import { useRecommendationEventStore } from '@/store/recommendationEventStore';
import { createRecommendationEventContext } from '@/utils/recommendationEventContext';
+import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals';
+
+const webGlassPlayerStyle = {
+ backdropFilter: 'blur(24px) saturate(155%)',
+ WebkitBackdropFilter: 'blur(24px) saturate(155%)',
+};
export function MiniPlayer() {
const insets = useSafeAreaInsets();
- const { currentTrack, isPlaying, playlistId, toggle } = usePlayerStore();
+ const {
+ currentTrack,
+ isPlaying,
+ playNext,
+ playPrevious,
+ playlistId,
+ queue,
+ toggle,
+ } = usePlayerStore();
const { isLiked, isSaved, toggleLike, toggleSave } = useLibraryStore();
const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent);
const [isActionMenuVisible, setIsActionMenuVisible] = useState(false);
+ const [isFullPlayerVisible, setIsFullPlayerVisible] = useState(false);
if (!currentTrack) {
return null;
@@ -24,6 +41,10 @@ export function MiniPlayer() {
const liked = isLiked(currentTrack.id);
const saved = isSaved(currentTrack.id);
+ const keyColor = getTrackKeyColor(currentTrack);
+ const playerGlow = hexToRgba(keyColor, 0.72);
+ const playerSoftGlow = hexToRgba(keyColor, 0.24);
+ const canSkip = queue.length > 1;
const handleToggleLike = () => {
toggleLike(currentTrack, playlistId);
addRecommendationEvent({
@@ -51,53 +72,309 @@ export function MiniPlayer() {
type: saved ? 'track_unsave' : 'track_save',
});
};
+ const handlePlayNext = () => {
+ if (!canSkip) {
+ return;
+ }
+
+ playNext();
+ };
+ const handlePlayPrevious = () => {
+ if (!canSkip) {
+ return;
+ }
+
+ playPrevious();
+ };
+ const renderCover = (sizeClassName: string, radiusClassName: string) => (
+
+ {currentTrack.albumImageUrl ? (
+
+ ) : (
+
+ )}
+
+ );
+ const renderLpCover = () => (
+
+
+
+
+
+
+ {currentTrack.albumImageUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
return (
<>
-
-
-
- {currentTrack.title}
-
-
- {currentTrack.artist}
-
-
-
-
-
-
-
-
-
+
+
setIsActionMenuVisible(true)}
+ onPress={() => setIsFullPlayerVisible(true)}
>
-
+ {renderCover('h-[68px] w-[68px]', 'rounded-[22px]')}
+
+
+
+ {currentTrack.title}
+
+
+ {currentTrack.artist}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setIsActionMenuVisible(true)}
+ >
+
+
+
+ setIsFullPlayerVisible(false)}
+ transparent
+ visible={isFullPlayerVisible}
+ >
+
+
+
+
+
+ setIsFullPlayerVisible(false)}
+ >
+
+
+
+
+ {currentTrack.title}
+
+
+ {currentTrack.artist}
+
+
+ setIsActionMenuVisible(true)}
+ >
+
+
+
+
+
+
+ {renderLpCover()}
+
+
+
+
+
+ {currentTrack.title}
+
+
+ {currentTrack.artist}
+
+
+
+
+
+
+
+
+ {isPlaying ? '0:48' : '0:00'}
+ 2:58
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
{children}
);
diff --git a/src/components/home/FeaturedPlaylistCard.tsx b/src/components/home/FeaturedPlaylistCard.tsx
index cedffdb..5177bb1 100644
--- a/src/components/home/FeaturedPlaylistCard.tsx
+++ b/src/components/home/FeaturedPlaylistCard.tsx
@@ -15,11 +15,11 @@ export function FeaturedPlaylistCard({ playlist }: FeaturedPlaylistCardProps) {
className="mr-4 h-[260px] w-[180px] justify-end overflow-hidden rounded-[20px] border border-white/10 bg-soundlog-card p-4"
onPress={() => router.push(`/playlist/${playlist.id}`)}
>
+
-
-
+
+
-
{playlist.regionName}
{playlist.description}
diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx
index 464f200..e0b8eee 100644
--- a/src/components/home/HomeHeader.tsx
+++ b/src/components/home/HomeHeader.tsx
@@ -1,7 +1,11 @@
-import { ScrollView, View } from 'react-native';
+import { Feather } from '@expo/vector-icons';
+import { Pressable, ScrollView, View } from 'react-native';
+import { AppText } from '@/components/AppText';
import { BrandLogo } from '@/components/BrandLogo';
import { Chip } from '@/components/Chip';
+import { colors } from '@/constants/colors';
+import type { MusicRecommendationMode, PlaceContext } from '@/types/domain';
const topFilters = ['전체', '근처', '지역 트렌드', '내 취향', '저장 많은'];
@@ -10,17 +14,132 @@ export function isHomeTopFilter(filter: string) {
}
type HomeHeaderProps = {
+ currentPlace?: PlaceContext;
+ isLocationLoading?: boolean;
+ recommendationMode: MusicRecommendationMode;
+ onSelectRecommendationMode: (mode: MusicRecommendationMode) => void;
+ onSetCurrentLocation: () => void;
+};
+
+type HomeTopFilterBarProps = {
selectedTopFilter: string;
onSelectTopFilter: (filter: string) => void;
};
+const musicModeOptions: Array<{
+ accent: string;
+ label: string;
+ value: MusicRecommendationMode;
+}> = [
+ {
+ accent: colors.accent.blue,
+ label: 'Everyday',
+ value: 'everyday',
+ },
+ {
+ accent: colors.accent.lime,
+ label: 'Travel',
+ value: 'travel',
+ },
+];
+
export function HomeHeader({
+ currentPlace,
+ isLocationLoading = false,
+ onSelectRecommendationMode,
+ onSetCurrentLocation,
+ recommendationMode,
+}: HomeHeaderProps) {
+ const activeMode =
+ musicModeOptions.find((mode) => mode.value === recommendationMode) ??
+ musicModeOptions[0];
+ const locationLabel = currentPlace?.title ?? '위치를 확인해 볼까요?';
+
+ return (
+
+
+
+
+
+
+ Music Mode
+
+
+ {recommendationMode === 'travel' ? '여행지에 맞는 음악' : '평소 취향 중심 추천'}
+
+
+
+
+
+
+
+
+
+
+ {musicModeOptions.map((mode) => {
+ const selected = recommendationMode === mode.value;
+
+ return (
+ onSelectRecommendationMode(mode.value)}
+ style={{
+ backgroundColor: selected ? mode.accent : 'transparent',
+ transform: [{ scale: selected ? 1 : 0.98 }],
+ }}
+ >
+
+ {mode.label}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ {locationLabel}
+
+
+
+
+
+ {isLocationLoading ? '확인 중' : '위치 설정'}
+
+
+
+
+ );
+}
+
+export function HomeTopFilterBar({
onSelectTopFilter,
selectedTopFilter,
-}: HomeHeaderProps) {
+}: HomeTopFilterBarProps) {
return (
-
-
+
{topFilters.map((filter) => (
diff --git a/src/components/home/LocationContextCard.tsx b/src/components/home/LocationContextCard.tsx
index e2e3c4c..1690eb6 100644
--- a/src/components/home/LocationContextCard.tsx
+++ b/src/components/home/LocationContextCard.tsx
@@ -12,6 +12,7 @@ type LocationContextCardProps = {
isLoading?: boolean;
isPlaceLoading?: boolean;
location?: GeoPoint;
+ onDismiss?: () => void;
onEnable: () => void;
onRefresh: () => void;
place?: PlaceContext;
@@ -35,7 +36,7 @@ const statusCopy: Record
) : null}
+
+ {onDismiss ? (
+
+
+
+ ) : null}
- {buttonLabel}
+ {buttonLabel}
);
diff --git a/src/components/home/TravelModeSuggestionSheet.tsx b/src/components/home/TravelModeSuggestionSheet.tsx
new file mode 100644
index 0000000..06fa72a
--- /dev/null
+++ b/src/components/home/TravelModeSuggestionSheet.tsx
@@ -0,0 +1,73 @@
+import { Feather } from '@expo/vector-icons';
+import { Pressable, View } from 'react-native';
+
+import { AppText } from '@/components/AppText';
+import type { PlaceContext } from '@/types/domain';
+
+type TravelModeSuggestionSheetProps = {
+ onDismiss: () => void;
+ onStartTravelMode: () => void;
+ place: PlaceContext;
+};
+
+export function TravelModeSuggestionSheet({
+ onDismiss,
+ onStartTravelMode,
+ place,
+}: TravelModeSuggestionSheetProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {place.title}에 도착했습니다
+
+
+ 현재 위치에 맞는 음악을 추천받으시겠습니까?
+
+
+
+
+
+
+ Travel Mode는 직접 선택할 때만 시작돼요
+
+
+ 위치 60%, 관광지 유형 20%, 시간대 10%, 취향 10%를 반영합니다.
+
+
+
+
+
+
+ Travel Mode 시작
+
+
+
+
+ 나중에
+
+
+
+
+ );
+}
diff --git a/src/components/home/TravelSessionCard.tsx b/src/components/home/TravelSessionCard.tsx
index db46d52..23fc86a 100644
--- a/src/components/home/TravelSessionCard.tsx
+++ b/src/components/home/TravelSessionCard.tsx
@@ -1,9 +1,9 @@
import { Feather } from '@expo/vector-icons';
-import { Pressable, View } from 'react-native';
+import { Pressable, ScrollView, View } from 'react-native';
import { AppText } from '@/components/AppText';
import { Chip } from '@/components/Chip';
-import { TravelMode } from '@/types/domain';
+import type { TravelMode } from '@/types/domain';
import { formatRecapRecordedAt } from '@/utils/dateFormat';
type TravelSessionStatus = 'active' | 'ended' | 'idle';
@@ -81,56 +81,84 @@ export function TravelSessionCard({
}: TravelSessionCardProps) {
const copy = statusCopy[status];
const onPrimaryPress = status === 'active' ? onEndSession : onStartSession;
+ const sessionTime = formatSessionTime(status, startedAt, endedAt);
+ const selectedModeLabel = travelModeOptions.find(
+ (mode) => mode.value === selectedMode,
+ )?.label;
return (
-
-
-
-
+
+
+
+
- {copy.title}
- {copy.description}
-
- {formatSessionTime(status, startedAt, endedAt)}
+
+
+
+ {copy.title}
+
+
+ {selectedModeLabel ? (
+
+
+ {selectedModeLabel}
+
+
+ ) : null}
+
+
+ {sessionTime}
-
-
-
- 여행 모드
-
- {travelModeOptions.map((mode) => (
- onSelectMode(mode.value)}
- selected={selectedMode === mode.value}
- />
- ))}
-
-
-
- {copy.cta}
+ {copy.cta}
+
+
+ {status !== 'active' ? (
+
+ {copy.description}
+
+ ) : null}
- {status === 'ended' ? (
+
+ 여행 모드
+
+
+ {travelModeOptions.map((mode) => (
+ onSelectMode(mode.value)}
+ selected={selectedMode === mode.value}
+ />
+ ))}
+
+
+
+
+ {status === 'ended' ? (
+
- Recap 보기
+ Recap 보기
- ) : null}
-
+
+ ) : null}
);
}
diff --git a/src/components/onboarding/OnboardingScreen.tsx b/src/components/onboarding/OnboardingScreen.tsx
index 6204b4d..4aff791 100644
--- a/src/components/onboarding/OnboardingScreen.tsx
+++ b/src/components/onboarding/OnboardingScreen.tsx
@@ -1,10 +1,10 @@
import { router, useLocalSearchParams } from 'expo-router';
+import { LinearGradient } from 'expo-linear-gradient';
import { useMemo, useState } from 'react';
import { Pressable, ScrollView, Switch, View } from 'react-native';
import { AppText } from '@/components/AppText';
import { BrandLogo } from '@/components/BrandLogo';
-import { Chip } from '@/components/Chip';
import { Screen } from '@/components/Screen';
import { useHomeFilterStore } from '@/store/homeFilterStore';
import {
@@ -22,30 +22,47 @@ type OnboardingStep = {
const steps: OnboardingStep[] = [
{
- description: '처음 추천을 만들 때 가장 먼저 참고할 음악 취향이에요.',
+ description: '첫 추천의 베이스가 되는 장르 데이터를 받아요.',
key: 'preferredGenres',
- title: '어떤 음악을 자주 들으세요?',
+ title: '음악 장르는?',
},
{
- description: '여행지에서 듣고 싶은 기본 분위기를 골라주세요.',
+ description: '장소와 시간대에 맞출 감정 에너지를 골라주세요.',
key: 'preferredMoods',
- title: '좋아하는 무드를 알려주세요',
+ title: '끌리는 무드는 어떤 쪽인가요?',
},
{
- description: '추천 플레이리스트와 홈 필터의 기본값으로 활용돼요.',
+ description: '관광 맥락과 플레이리스트 템포를 맞추는 데 사용돼요.',
key: 'travelStyles',
- title: '선호하는 여행 스타일은요?',
+ title: '여행 중 어떤 장면이 많나요?',
},
{
description: '동행과 위치 추천 여부에 따라 추천 톤을 조정해요.',
key: 'companion',
- title: '이번 여행은 보통 누구와 함께하나요?',
+ title: '누구와 듣는 여행인가요?',
},
];
const options: Record = {
- preferredGenres: ['K-POP', '팝', '인디', '발라드', '힙합', 'R&B', 'OST'],
- preferredMoods: ['잔잔한', '신나는', '청량한', '감성적인', '활기찬'],
+ preferredGenres: [
+ 'K-POP',
+ '팝',
+ '인디',
+ '발라드',
+ '힙합',
+ 'R&B',
+ 'OST',
+ '시티팝',
+ ],
+ preferredMoods: [
+ '잔잔한',
+ '신나는',
+ '청량한',
+ '감성적인',
+ '활기찬',
+ '몽환적인',
+ '드라이브',
+ ],
travelStyles: [
'산책',
'드라이브',
@@ -58,6 +75,20 @@ const options: Record = {
const companionOptions = ['혼자', '친구', '연인', '가족'];
+const stepAccents = [
+ ['#1DB954', '#7CFF8A'],
+ ['#27D3FF', '#B66BFF'],
+ ['#FFB84D', '#FF4FD8'],
+ ['#1DB954', '#2CE6B8'],
+] as const;
+
+const stepHints = [
+ 'Genre seed',
+ 'Mood tempo',
+ 'Travel context',
+ 'Social mix',
+] as const;
+
function toggleValue(values: string[], value: string) {
return values.includes(value)
? values.filter((item) => item !== value)
@@ -89,6 +120,8 @@ export function OnboardingScreen() {
const currentStep = steps[currentStepIndex];
const isLastStep = currentStepIndex === steps.length - 1;
const progressLabel = `${currentStepIndex + 1}/${steps.length}`;
+ const currentAccent = stepAccents[currentStepIndex];
+ const currentHint = stepHints[currentStepIndex];
const selectedCount = useMemo(
() =>
@@ -99,6 +132,16 @@ export function OnboardingScreen() {
[draft],
);
+ const currentStepSelectedCount = useMemo(() => {
+ if (currentStep.key === 'companion') {
+ return draft.companionType ? 1 : 0;
+ }
+
+ return draft[currentStep.key].length;
+ }, [currentStep.key, draft]);
+
+ const canProceed = currentStepSelectedCount > 0;
+
const updateMultiSelect = (key: MultiSelectKey, value: string) => {
setDraft((prev) => ({ ...prev, [key]: toggleValue(prev[key], value) }));
};
@@ -122,6 +165,10 @@ export function OnboardingScreen() {
};
const handlePrimaryPress = () => {
+ if (!canProceed) {
+ return;
+ }
+
if (!isLastStep) {
setCurrentStepIndex((prev) => prev + 1);
return;
@@ -148,24 +195,39 @@ export function OnboardingScreen() {
{companionOptions.map((option) => (
-
setDraft((prev) => ({ ...prev, companionType: option }))
}
- selected={draft.companionType === option}
- />
+ >
+
+ {option}
+
+
))}
-
+
위치 기반 추천
-
+
현재 장소와 가까운 관광 맥락을 음악 추천에 반영해요.
@@ -179,7 +241,7 @@ export function OnboardingScreen() {
thumbColor="#ffffff"
trackColor={{
false: 'rgba(255,255,255,0.18)',
- true: '#7A2CFF',
+ true: '#1DB954',
}}
value={draft.locationRecommendationEnabled}
/>
@@ -194,12 +256,27 @@ export function OnboardingScreen() {
return (
{options[multiSelectKey].map((option) => (
- updateMultiSelect(multiSelectKey, option)}
- selected={draft[multiSelectKey].includes(option)}
- />
+ >
+
+ {option}
+
+
))}
);
@@ -210,7 +287,7 @@ export function OnboardingScreen() {
-
- {isEditMode ? 'Soundlog profile' : 'Soundlog setup'}
-
-
+
+
+ {isEditMode ? 'Soundlog profile' : 'Soundlog taste scan'}
+
+
+
{isEditMode
? '지금 여행 취향에 맞게\n추천을 다시 맞춰요'
- : '여행 취향을 알수록\n선곡이 더 가까워져요'}
+ : '내 여행을 닮은\n믹스를 만들어볼게요'}
-
+
{isEditMode
? '수정한 값은 홈 추천과 무드 필터의 기본값으로 다시 반영돼요.'
- : '입력한 값은 로컬에 저장되고, 홈 추천과 무드 필터의 기본값으로 사용돼요.'}
+ : '장르, 무드, 여행 장면을 조합해 위치 기반 추천의 첫 플레이리스트를 세팅해요.'}
-
-
-
- {progressLabel}
+
+
+
+
+
+ {currentHint}
+
+
+ {progressLabel}
+
+
+
+
+ 전체 선택 {selectedCount}개
+
+
+
+
+
+
+
+
+
+ {[22, 34, 18, 42, 28, 50, 24, 38].map((height, index) => (
+
+ ))}
+
+
+
+ {currentStep.title}
-
- 선택 {selectedCount}개
+
+ {currentStep.description}
-
-
-
-
-
-
- {currentStep.title}
-
-
- {currentStep.description}
-
+ {renderStepBody()}
- {renderStepBody()}
-
+ {!canProceed ? (
+
+ 하나 이상 선택하면 다음 믹스로 넘어갈 수 있어요.
+
+ ) : null}
+
+
-
+
{isLastStep
? isEditMode
? '수정 완료'
@@ -285,17 +420,25 @@ export function OnboardingScreen() {
- {currentStepIndex > 0 ? (
- setCurrentStepIndex((prev) => prev - 1)}
+ setCurrentStepIndex((prev) => prev - 1)}
+ >
+
-
- 이전
-
-
- ) : null}
+ 이전
+
+
diff --git a/src/components/playlist/PlaylistBottomSheet.tsx b/src/components/playlist/PlaylistBottomSheet.tsx
index 9d74f42..25a1e5b 100644
--- a/src/components/playlist/PlaylistBottomSheet.tsx
+++ b/src/components/playlist/PlaylistBottomSheet.tsx
@@ -1,14 +1,50 @@
-import { PropsWithChildren } from 'react';
-import { View } from 'react-native';
+import { PropsWithChildren, ReactNode } from 'react';
+import { ScrollView, useWindowDimensions, View } from 'react-native';
+
+const COLLAPSED_TOP = 205;
+const SHEET_BACKGROUND = 'rgba(5, 9, 22, 0.78)';
+
+type PlaylistBottomSheetProps = PropsWithChildren<{
+ stickyHeader?: ReactNode;
+}>;
+
+export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSheetProps) {
+ const { height } = useWindowDimensions();
-export function PlaylistBottomSheet({ children }: PropsWithChildren) {
return (
-
-
- {children}
-
+ {stickyHeader ? (
+ [
+
+
+ {stickyHeader}
+ ,
+
+ {children}
+ ,
+ ]
+ ) : (
+
+
+ {children}
+
+ )}
+
);
}
diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx
index 9fdbb15..0adb3f9 100644
--- a/src/components/playlist/PlaylistCurationScreen.tsx
+++ b/src/components/playlist/PlaylistCurationScreen.tsx
@@ -81,7 +81,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro
return;
}
- setTrack(track, playlist.id);
+ setTrack(track, playlist.id, playlist.tracks);
addRecommendationEvent({
context: createRecommendationEventContext(),
playlistId: playlist.id,
@@ -136,7 +136,17 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro
-
+
+ ) : undefined
+ }
+ >
{isLoading ? (
) : isError ? (
@@ -144,22 +154,15 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro
) : !playlist ? (
) : (
- <>
-
- setSelectedTrackId(track.id)}
- onSelectTrack={playTrack}
- savedTrackIds={savedTrackIds}
- tracks={playlist.tracks}
- />
- >
+ setSelectedTrackId(track.id)}
+ onSelectTrack={playTrack}
+ savedTrackIds={savedTrackIds}
+ tracks={playlist.tracks}
+ />
)}
diff --git a/src/components/playlist/TrackList.tsx b/src/components/playlist/TrackList.tsx
index e9d9d3d..0017643 100644
--- a/src/components/playlist/TrackList.tsx
+++ b/src/components/playlist/TrackList.tsx
@@ -1,4 +1,4 @@
-import { FlatList, View } from 'react-native';
+import { View } from 'react-native';
import { AppText } from '@/components/AppText';
import { TrackRow } from '@/components/playlist/TrackRow';
@@ -37,14 +37,10 @@ export function TrackList({
}
return (
- item.id}
- nestedScrollEnabled
- renderItem={({ item }) => (
+
+ {tracks.map((item) => (
- )}
- showsVerticalScrollIndicator={false}
- />
+ ))}
+
);
}
diff --git a/src/components/playlist/TrackRow.tsx b/src/components/playlist/TrackRow.tsx
index 603f801..f6e0868 100644
--- a/src/components/playlist/TrackRow.tsx
+++ b/src/components/playlist/TrackRow.tsx
@@ -1,9 +1,11 @@
import { Feather } from '@expo/vector-icons';
import { Image } from 'expo-image';
+import { LinearGradient } from 'expo-linear-gradient';
import { Pressable, View } from 'react-native';
import { AppText } from '@/components/AppText';
import { Track } from '@/types/domain';
+import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals';
type TrackRowProps = {
isActive: boolean;
@@ -15,45 +17,78 @@ type TrackRowProps = {
};
export function TrackRow({ isActive, isLiked, isSaved, onMore, onPress, track }: TrackRowProps) {
+ const keyColor = getTrackKeyColor(track);
+ const activeBackground = hexToRgba(keyColor, 0.78);
+ const activeGlow = hexToRgba(keyColor, 0.24);
+
return (
- onPress(track)}
- style={{ backgroundColor: isActive ? 'rgba(255,255,255,0.08)' : 'transparent' }}
+
-
- {track.albumImageUrl ? (
-
- ) : null}
-
+ />
-
-
-
- {track.title}
-
- {isLiked ? : null}
- {isSaved ? : null}
-
-
- {track.artist}
-
-
+
+ onPress(track)}
+ >
+
+ {track.albumImageUrl ? (
+
+ ) : (
+
+ )}
+
- onMore(track)}
- >
-
-
-
+
+
+
+ {track.title}
+
+ {isLiked ? : null}
+ {isSaved ? : null}
+
+
+ {track.artist}
+
+
+
+
+ onMore(track)}
+ >
+
+
+
+
);
}
diff --git a/src/components/recap-share/RecapCaptureFrame.tsx b/src/components/recap-share/RecapCaptureFrame.tsx
index ba162d3..e1b30fb 100644
--- a/src/components/recap-share/RecapCaptureFrame.tsx
+++ b/src/components/recap-share/RecapCaptureFrame.tsx
@@ -1,5 +1,5 @@
-import { forwardRef, PropsWithChildren, useImperativeHandle, useRef } from 'react';
-import ViewShot, { ViewShotRef } from 'react-native-view-shot';
+import { ComponentRef, forwardRef, PropsWithChildren, useImperativeHandle, useRef } from 'react';
+import ViewShot from 'react-native-view-shot';
export type RecapCaptureFrameHandle = {
capture: () => Promise;
@@ -9,7 +9,7 @@ type RecapCaptureFrameProps = PropsWithChildren;
export const RecapCaptureFrame = forwardRef(
function RecapCaptureFrame({ children }, ref) {
- const viewShotRef = useRef(null);
+ const viewShotRef = useRef>(null);
useImperativeHandle(ref, () => ({
capture: () => viewShotRef.current?.capture?.() ?? Promise.resolve(undefined),
diff --git a/src/constants/colors.ts b/src/constants/colors.ts
index 50a114b..4509ee2 100644
--- a/src/constants/colors.ts
+++ b/src/constants/colors.ts
@@ -1,24 +1,44 @@
export const colors = {
+ brand: {
+ limeWave: '#B7E628',
+ },
background: {
- primary: '#050916',
- secondary: '#090E1B',
- deepPurple: '#160F27',
+ primary: '#070B1F',
+ secondary: '#0B102A',
+ deepPurple: '#170738',
+ gradient: ['#070B1F', '#070B1F', '#070B1F', '#070B1F', '#070B1F'],
+ aurora: [
+ 'rgba(7,11,31,0)',
+ 'rgba(7,11,31,0)',
+ 'rgba(7,11,31,0)',
+ 'rgba(7,11,31,0)',
+ 'rgba(7,11,31,0)',
+ ],
},
surface: {
card: '#080D18',
- chip: '#0E1E3A',
+ cardElevated: '#090E1B',
+ chip: '#171B2A',
+ chipSelected: '#B7E628',
player: '#45343D',
- tab: 'rgba(10, 16, 30, 0.94)',
+ tab: 'rgba(10, 16, 30, 0.58)',
+ glass: 'rgba(255,255,255,0.1)',
},
border: {
+ subtle: 'rgba(255,255,255,0.12)',
chip: '#364283',
+ focus: '#B7E628',
},
accent: {
+ blue: '#6EA8FF',
purple: '#7A2CFF',
gold: '#B1913A',
+ lime: '#B7E628',
},
text: {
primary: '#FFFFFF',
secondary: '#ACACAC',
+ muted: 'rgba(255,255,255,0.58)',
+ inverse: '#090515',
},
} as const;
diff --git a/src/constants/layout.ts b/src/constants/layout.ts
index dad9516..f6b6a80 100644
--- a/src/constants/layout.ts
+++ b/src/constants/layout.ts
@@ -1,6 +1,6 @@
export const layout = {
miniPlayerGap: 12,
- miniPlayerHeight: 67,
+ miniPlayerHeight: 96,
screenX: 20,
tabBarBaseHeight: 76,
} as const;
diff --git a/src/mock-server/homeHandlers.ts b/src/mock-server/homeHandlers.ts
index 8139031..105aafa 100644
--- a/src/mock-server/homeHandlers.ts
+++ b/src/mock-server/homeHandlers.ts
@@ -26,6 +26,17 @@ function getMatchScore(
const itemGenres = item.genres ?? [];
const itemMoods = item.moods ?? [];
const itemTravelStyles = item.travelStyles ?? [];
+ const placeContext = [
+ params.currentPlace?.title,
+ params.currentPlace?.category,
+ params.currentPlace?.contentType,
+ params.currentPlace?.overview,
+ ]
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase();
+ const travelModeWeight = params.recommendationMode === 'travel' ? 2.4 : 1;
+ const tasteWeight = params.recommendationMode === 'travel' ? 0.7 : 1.4;
if (params.topFilter !== '전체' && itemMoods.includes(params.topFilter)) {
score += 8;
@@ -37,14 +48,30 @@ function getMatchScore(
score +=
(params.preferredGenres ?? []).filter((genre) => itemGenres.includes(genre))
- .length * 3;
+ .length * 3 * tasteWeight;
score +=
(params.preferredMoods ?? []).filter((mood) => itemMoods.includes(mood))
- .length * 2;
+ .length * 2 * tasteWeight;
score +=
(params.travelStyles ?? []).filter((style) =>
itemTravelStyles.includes(style),
- ).length * 2;
+ ).length * 2 * travelModeWeight;
+
+ if (params.recommendationMode === 'travel') {
+ if (/궁|궁궐|문화|역사|전통|palace|heritage/.test(placeContext)) {
+ score += itemGenres.some((genre) => /국악|ambient|fusion|클래식/i.test(genre))
+ ? 18
+ : 0;
+ }
+
+ if (/해변|바다|해수욕장|ocean|beach/.test(placeContext)) {
+ score += itemMoods.some((mood) => /청량한|잔잔한|신나는/.test(mood)) ? 18 : 0;
+ }
+
+ if (/야경|타워|전망|city|night/.test(placeContext)) {
+ score += item.travelStyles?.includes('야경 감상') ? 18 : 0;
+ }
+ }
return score;
}
@@ -53,7 +80,11 @@ function getFeaturedPlaylistLocationScore(
item: FeaturedPlaylist,
params?: FeaturedPlaylistMockParams,
) {
- if (!params?.locationRecommendationEnabled || !params.location) {
+ if (
+ !params?.locationRecommendationEnabled ||
+ !params.location ||
+ params.recommendationMode !== 'travel'
+ ) {
return 0;
}
diff --git a/src/mock-server/types.ts b/src/mock-server/types.ts
index f1e3cca..aed6a0e 100644
--- a/src/mock-server/types.ts
+++ b/src/mock-server/types.ts
@@ -1,6 +1,7 @@
import {
FeaturedPlaylist,
GeoPoint,
+ MusicRecommendationMode,
MoodRecommendation,
MusicLogItem,
PlaceContext,
@@ -26,11 +27,14 @@ export type MockDelayOptions = {
export type FeaturedPlaylistMockParams = {
location?: GeoPoint;
locationRecommendationEnabled: boolean;
+ recommendationMode: MusicRecommendationMode;
place?: PlaceContext;
};
export type MoodRecommendationMockParams = {
+ currentPlace?: PlaceContext;
moodFilter: string;
+ recommendationMode: MusicRecommendationMode;
preferredGenres?: string[];
preferredMoods?: string[];
topFilter: string;
diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts
index 775e884..59427ed 100644
--- a/src/mocks/homeMocks.ts
+++ b/src/mocks/homeMocks.ts
@@ -1,6 +1,13 @@
import { FeaturedPlaylist, MoodRecommendation, MusicLogItem } from '@/types/domain';
export const featuredPlaylists: FeaturedPlaylist[] = [
+ {
+ id: 'geoje-ocean',
+ regionName: '거제',
+ description: '거제 바다와 섬길을 따라 듣기 좋은 플레이리스트예요.',
+ durationText: '32:00분',
+ trackCount: 8,
+ },
{
id: 'busan-ocean',
regionName: '부산',
diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts
index 0c08518..f60038a 100644
--- a/src/mocks/playlistMocks.ts
+++ b/src/mocks/playlistMocks.ts
@@ -34,6 +34,100 @@ const tracks: Track[] = [
{ artist: '10cm', fallbackColor: '#DA6C51', id: 'seoul-night-track', title: '서울의 밤' },
];
+const geojeTracks: Track[] = [
+ {
+ artist: 'AKMU',
+ fallbackColor: '#1D7F8C',
+ id: 'geoje-dinosaur-ridge',
+ platformUrls: {
+ melon:
+ 'https://www.melon.com/search/total/index.htm?q=AKMU%20Dinosaur',
+ spotify: 'https://open.spotify.com/search/AKMU%20Dinosaur',
+ youtubeMusic: 'https://music.youtube.com/search?q=AKMU%20Dinosaur',
+ },
+ title: 'Dinosaur',
+ },
+ {
+ artist: '볼빨간사춘기',
+ fallbackColor: '#E66A73',
+ id: 'geoje-travel',
+ platformUrls: {
+ melon:
+ 'https://www.melon.com/search/total/index.htm?q=%EB%B3%BC%EB%B9%A8%EA%B0%84%EC%82%AC%EC%B6%98%EA%B8%B0%20%EC%97%AC%ED%96%89',
+ spotify:
+ 'https://open.spotify.com/search/%EB%B3%BC%EB%B9%A8%EA%B0%84%EC%82%AC%EC%B6%98%EA%B8%B0%20%EC%97%AC%ED%96%89',
+ youtubeMusic:
+ 'https://music.youtube.com/search?q=%EB%B3%BC%EB%B9%A8%EA%B0%84%EC%82%AC%EC%B6%98%EA%B8%B0%20%EC%97%AC%ED%96%89',
+ },
+ title: '여행',
+ },
+ {
+ artist: '잔나비',
+ fallbackColor: '#D29B42',
+ id: 'geoje-summer',
+ platformUrls: {
+ spotify:
+ 'https://open.spotify.com/search/%EC%9E%94%EB%82%98%EB%B9%84%20%EB%9C%A8%EA%B1%B0%EC%9A%B4%20%EC%97%AC%EB%A6%84%EB%B0%A4%EC%9D%80%20%EA%B0%80%EA%B3%A0%20%EB%82%A8%EC%9D%80%20%EA%B1%B4%20%EB%B3%BC%ED%92%88%EC%97%86%EC%A7%80%EB%A7%8C',
+ youtubeMusic:
+ 'https://music.youtube.com/search?q=%EC%9E%94%EB%82%98%EB%B9%84%20%EB%9C%A8%EA%B1%B0%EC%9A%B4%20%EC%97%AC%EB%A6%84%EB%B0%A4%EC%9D%80%20%EA%B0%80%EA%B3%A0%20%EB%82%A8%EC%9D%80%20%EA%B1%B4%20%EB%B3%BC%ED%92%88%EC%97%86%EC%A7%80%EB%A7%8C',
+ },
+ title: '뜨거운 여름밤은 가고 남은 건 볼품없지만',
+ },
+ {
+ artist: 'wave to earth',
+ fallbackColor: '#2D6A72',
+ id: 'geoje-seasons',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/wave%20to%20earth%20seasons',
+ youtubeMusic: 'https://music.youtube.com/search?q=wave%20to%20earth%20seasons',
+ },
+ title: 'seasons',
+ },
+ {
+ artist: '혁오',
+ fallbackColor: '#45536B',
+ id: 'geoje-wi-ing',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/hyukoh%20wi%20ing%20wi%20ing',
+ youtubeMusic: 'https://music.youtube.com/search?q=hyukoh%20wi%20ing%20wi%20ing',
+ },
+ title: '위잉위잉',
+ },
+ {
+ artist: '오존',
+ fallbackColor: '#6C7F99',
+ id: 'geoje-down',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/O3ohn%20Down',
+ youtubeMusic: 'https://music.youtube.com/search?q=O3ohn%20Down',
+ },
+ title: 'Down',
+ },
+ {
+ artist: '카더가든',
+ fallbackColor: '#334D3F',
+ id: 'geoje-tree',
+ platformUrls: {
+ spotify:
+ 'https://open.spotify.com/search/%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4',
+ youtubeMusic:
+ 'https://music.youtube.com/search?q=%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4',
+ },
+ title: '나무',
+ },
+ {
+ artist: 'The Black Skirts',
+ fallbackColor: '#1F2937',
+ id: 'geoje-everything',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/The%20Black%20Skirts%20Everything',
+ youtubeMusic:
+ 'https://music.youtube.com/search?q=The%20Black%20Skirts%20Everything',
+ },
+ title: 'Everything',
+ },
+];
+
export const playlistDetail: PlaylistCuration = {
backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg',
coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg',
@@ -47,6 +141,17 @@ export const playlistDetail: PlaylistCuration = {
};
export const playlistCurationById: Record = {
+ 'geoje-ocean': {
+ backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg',
+ coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg',
+ durationText: '32:00분',
+ id: 'geoje-ocean',
+ placeName: '바람의 언덕과 해안도로',
+ reason: '거제의 바다 바람과 섬길 드라이브에 어울리는 음악이에요',
+ regionName: '거제',
+ trackCount: geojeTracks.length,
+ tracks: geojeTracks,
+ },
'busan-ocean': {
...playlistDetail,
backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg',
diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx
index 3d3b9d7..c624557 100644
--- a/src/providers/AppProviders.tsx
+++ b/src/providers/AppProviders.tsx
@@ -1,9 +1,10 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { PropsWithChildren } from 'react';
+import { Platform } from 'react-native';
import { queryClient } from '@/providers/queryClient';
-const DevTestManager = __DEV__
+const DevTestManager = __DEV__ && Platform.OS !== 'web'
? require('@/components/dev/DevTestManager').DevTestManager
: undefined;
diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts
index b05a0d3..cb477f6 100644
--- a/src/store/playerStore.ts
+++ b/src/store/playerStore.ts
@@ -8,27 +8,62 @@ type PlayerState = {
currentTrack?: Track;
isPlaying: boolean;
playlistId?: string;
+ queue: Track[];
source: PlayerSource;
+ playNext: () => void;
+ playPrevious: () => void;
clearTrack: () => void;
- setTrack: (track: Track, playlistId?: string) => void;
+ setTrack: (track: Track, playlistId?: string, queue?: Track[]) => void;
toggle: () => void;
};
export const usePlayerStore = create((set) => ({
isPlaying: false,
+ queue: [],
source: 'none',
+ playNext: () =>
+ set((state) => {
+ if (!state.currentTrack || state.queue.length < 2) {
+ return state;
+ }
+
+ const currentIndex = state.queue.findIndex((track) => track.id === state.currentTrack?.id);
+ const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % state.queue.length : 0;
+
+ return {
+ currentTrack: state.queue[nextIndex],
+ isPlaying: true,
+ };
+ }),
+ playPrevious: () =>
+ set((state) => {
+ if (!state.currentTrack || state.queue.length < 2) {
+ return state;
+ }
+
+ const currentIndex = state.queue.findIndex((track) => track.id === state.currentTrack?.id);
+ const previousIndex =
+ currentIndex > 0 ? currentIndex - 1 : Math.max(state.queue.length - 1, 0);
+
+ return {
+ currentTrack: state.queue[previousIndex],
+ isPlaying: true,
+ };
+ }),
clearTrack: () =>
set({
currentTrack: undefined,
isPlaying: false,
playlistId: undefined,
+ queue: [],
source: 'none',
}),
- setTrack: (track, playlistId) =>
+ setTrack: (track, playlistId, queue) =>
set({
currentTrack: track,
isPlaying: true,
playlistId,
+ queue: queue?.length ? queue : [track],
source: 'external',
}),
toggle: () => set((state) => ({ isPlaying: !state.isPlaying })),
diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts
index 00cd4a2..54bd2b3 100644
--- a/src/store/recommendationEventStore.ts
+++ b/src/store/recommendationEventStore.ts
@@ -2,7 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
-import { TravelMode } from '@/types/domain';
+import { MusicRecommendationMode, TravelMode } from '@/types/domain';
export type RecommendationEventType =
| 'track_play'
@@ -13,10 +13,12 @@ export type RecommendationEventType =
| 'track_save'
| 'track_unsave'
| 'mood_filter_change'
+ | 'recommendation_mode_change'
| 'top_filter_change';
export type RecommendationEventContext = {
moodFilter?: string;
+ recommendationMode?: MusicRecommendationMode;
placeCategory?: string;
placeId?: string;
placeName?: string;
diff --git a/src/store/travelSessionStore.ts b/src/store/travelSessionStore.ts
index 9eb5a30..768aace 100644
--- a/src/store/travelSessionStore.ts
+++ b/src/store/travelSessionStore.ts
@@ -2,7 +2,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
-import { GeoPoint, PlaceContext, TravelMode } from '@/types/domain';
+import {
+ GeoPoint,
+ MusicRecommendationMode,
+ PlaceContext,
+ TravelMode,
+} from '@/types/domain';
export type HomeLocationStatus = 'denied' | 'granted' | 'idle' | 'loading' | 'unavailable';
@@ -18,6 +23,7 @@ type TravelSessionState = {
currentPlace?: PlaceContext;
locationStatus: HomeLocationStatus;
locationUpdatedAt?: string;
+ recommendationMode: MusicRecommendationMode;
selectedMode?: TravelMode;
session: TravelSession;
clearLocation: () => void;
@@ -27,6 +33,7 @@ type TravelSessionState = {
setPlace: (place?: PlaceContext) => void;
setLocationStatus: (status: HomeLocationStatus) => void;
setMode: (mode: TravelMode) => void;
+ setRecommendationMode: (mode: MusicRecommendationMode) => void;
startSession: () => void;
};
@@ -40,6 +47,7 @@ export const useTravelSessionStore = create()(
(set, get) => ({
session: idleSession,
locationStatus: 'idle',
+ recommendationMode: 'everyday',
clearLocation: () =>
set({
currentLocation: undefined,
@@ -75,6 +83,7 @@ export const useTravelSessionStore = create()(
setLocationStatus: (locationStatus) => set({ locationStatus }),
setMode: (selectedMode) => set({ selectedMode }),
setPlace: (currentPlace) => set({ currentPlace }),
+ setRecommendationMode: (recommendationMode) => set({ recommendationMode }),
startSession: () =>
set({
session: {
@@ -87,6 +96,7 @@ export const useTravelSessionStore = create()(
{
name: 'soundlog-travel-session',
partialize: (state) => ({
+ recommendationMode: state.recommendationMode,
selectedMode: state.selectedMode,
session: state.session,
}),
diff --git a/src/types/domain.ts b/src/types/domain.ts
index b2a1a0e..380fca6 100644
--- a/src/types/domain.ts
+++ b/src/types/domain.ts
@@ -18,6 +18,8 @@ export type PlaceContext = {
export type TravelMode = 'walk' | 'drive' | 'cafe' | 'ocean' | 'festival' | 'night';
+export type MusicRecommendationMode = 'everyday' | 'travel';
+
export type MoodTag = 'calm' | 'fresh' | 'emotional' | 'active' | 'local';
export type MusicPlatformId = 'melon' | 'none' | 'spotify' | 'youtubeMusic';
diff --git a/src/utils/recommendationEventContext.ts b/src/utils/recommendationEventContext.ts
index d65d0b7..146705c 100644
--- a/src/utils/recommendationEventContext.ts
+++ b/src/utils/recommendationEventContext.ts
@@ -6,10 +6,12 @@ export function createRecommendationEventContext(
overrides: RecommendationEventContext = {},
): RecommendationEventContext {
const { selectedMoodFilter, selectedTopFilter } = useHomeFilterStore.getState();
- const { currentPlace, selectedMode } = useTravelSessionStore.getState();
+ const { currentPlace, recommendationMode, selectedMode } =
+ useTravelSessionStore.getState();
return {
moodFilter: selectedMoodFilter,
+ recommendationMode,
placeCategory: currentPlace?.category,
placeId: currentPlace?.id,
placeName: currentPlace?.title,
diff --git a/src/utils/trackVisuals.ts b/src/utils/trackVisuals.ts
new file mode 100644
index 0000000..591d9f1
--- /dev/null
+++ b/src/utils/trackVisuals.ts
@@ -0,0 +1,44 @@
+import { Track } from '@/types/domain';
+
+const fallbackPalette = [
+ '#243A75',
+ '#20146F',
+ '#2D6A72',
+ '#45536B',
+ '#7A2CFF',
+ '#1D7F8C',
+];
+
+function hashString(value: string) {
+ return value.split('').reduce((hash, char) => {
+ return (hash * 31 + char.charCodeAt(0)) % 997;
+ }, 0);
+}
+
+function normalizeHexColor(color?: string) {
+ if (!color || !/^#[0-9A-Fa-f]{6}$/.test(color)) {
+ return undefined;
+ }
+
+ return color;
+}
+
+export function getTrackKeyColor(track?: Track) {
+ const keyColor = normalizeHexColor(track?.fallbackColor);
+
+ if (keyColor) {
+ return keyColor;
+ }
+
+ const seed = `${track?.id ?? ''}${track?.title ?? ''}${track?.artist ?? ''}`;
+ return fallbackPalette[hashString(seed) % fallbackPalette.length];
+}
+
+export function hexToRgba(hex: string, alpha: number) {
+ const normalized = normalizeHexColor(hex) ?? fallbackPalette[0];
+ const red = parseInt(normalized.slice(1, 3), 16);
+ const green = parseInt(normalized.slice(3, 5), 16);
+ const blue = parseInt(normalized.slice(5, 7), 16);
+
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 291342c..9b36f0f 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -7,13 +7,20 @@ module.exports = {
extend: {
colors: {
soundlog: {
- bg: '#050916',
+ bg: '#070B1F',
+ bg2: '#0B102A',
card: '#080D18',
- chip: '#0E1E3A',
+ elevated: '#090E1B',
+ chip: '#171B2A',
+ selected: '#B7E628',
border: '#364283',
+ focus: '#B7E628',
+ blue: '#6EA8FF',
purple: '#7A2CFF',
gold: '#B1913A',
+ lime: '#B7E628',
player: '#45343D',
+ inverse: '#090515',
},
},
},