From 6cf16fdb6a8914951f6eddb31999946477f7adc9 Mon Sep 17 00:00:00 2001 From: Su Date: Wed, 27 May 2026 22:55:13 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EA=B1=B0=EC=A0=9C=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/homeMocks.ts | 7 +++ src/mocks/playlistMocks.ts | 105 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) 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', From e70b9877b91eb3863332884ca4c8df5ff8fc0d0f Mon Sep 17 00:00:00 2001 From: Su Date: Wed, 27 May 2026 23:11:16 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlist/PlaylistBottomSheet.tsx | 55 ++++++++++++++++--- .../playlist/PlaylistCurationScreen.tsx | 37 +++++++------ src/components/playlist/TrackList.tsx | 17 ++---- 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/src/components/playlist/PlaylistBottomSheet.tsx b/src/components/playlist/PlaylistBottomSheet.tsx index 9d74f42..bb26ca9 100644 --- a/src/components/playlist/PlaylistBottomSheet.tsx +++ b/src/components/playlist/PlaylistBottomSheet.tsx @@ -1,14 +1,51 @@ -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..a406daf 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -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} - /> + ))} + ); } From 8c961055685332135cee7ef610e9fe842f6d508b Mon Sep 17 00:00:00 2001 From: Su Date: Sat, 6 Jun 2026 15:17:27 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=ED=8A=B8=EB=9E=98=EB=B8=94=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=ED=99=88=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 89 ++++++++---- app/(tabs)/my.tsx | 84 +++++++++++- src/api/homeQueries.ts | 9 +- src/components/Chip.tsx | 75 ++++++++++- src/components/home/HomeHeader.tsx | 127 +++++++++++++++++- src/components/home/LocationContextCard.tsx | 19 ++- .../home/TravelModeSuggestionSheet.tsx | 73 ++++++++++ src/components/home/TravelSessionCard.tsx | 94 ++++++++----- src/mock-server/homeHandlers.ts | 39 +++++- src/mock-server/types.ts | 4 + src/store/recommendationEventStore.ts | 4 +- src/store/travelSessionStore.ts | 12 +- src/types/domain.ts | 2 + src/utils/recommendationEventContext.ts | 4 +- 14 files changed, 559 insertions(+), 76 deletions(-) create mode 100644 src/components/home/TravelModeSuggestionSheet.tsx 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; }; +const chipTones = [ + { + active: [colors.brand.pulseMagenta, colors.brand.electricViolet], + border: 'rgba(215,24,241,0.72)', + inactive: ['rgba(215,24,241,0.32)', 'rgba(135,43,168,0.22)'], + text: '#FDE7FF', + }, + { + active: [colors.brand.electricViolet, colors.brand.deepIndigo], + border: 'rgba(79,42,236,0.76)', + inactive: ['rgba(79,42,236,0.34)', 'rgba(59,17,196,0.24)'], + text: '#ECE7FF', + }, + { + active: [colors.brand.signalPurple, colors.brand.pulseMagenta], + border: 'rgba(135,43,168,0.76)', + inactive: ['rgba(135,43,168,0.34)', 'rgba(215,24,241,0.2)'], + text: '#F7E6FF', + }, + { + active: [colors.brand.limeWave, '#D9FF5A'], + border: 'rgba(183,230,40,0.8)', + inactive: ['rgba(183,230,40,0.28)', 'rgba(79,42,236,0.16)'], + text: '#F5FFD0', + }, + { + active: [colors.brand.deepIndigo, colors.brand.electricViolet], + border: 'rgba(59,17,196,0.82)', + inactive: ['rgba(59,17,196,0.36)', 'rgba(79,42,236,0.2)'], + text: '#E8E2FF', + }, +] as const; + +function getChipTone(label: string) { + const seed = label.split('').reduce((hash, char) => { + return (hash * 31 + char.charCodeAt(0)) % 997; + }, 0); + + return chipTones[seed % chipTones.length]; +} + export function Chip({ label, onPress, selected = false }: ChipProps) { + const tone = getChipTone(label); + const gradientColors = selected ? tone.active : tone.inactive; + return ( - {label} + + + {label} + + ); } diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx index 464f200..a16798f 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.magenta, + 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/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/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, From 33f33be54277fffe5c80708f7300f30a0aff07a6 Mon Sep 17 00:00:00 2001 From: Su Date: Sat, 6 Jun 2026 15:17:43 +0900 Subject: [PATCH 4/6] Polish SoundLog music experience UI --- app/(tabs)/_layout.tsx | 10 +- docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md | 45 +++ src/components/MiniPlayer.tsx | 356 ++++++++++++++++-- src/components/Screen.tsx | 69 +++- src/components/home/FeaturedPlaylistCard.tsx | 2 +- .../onboarding/OnboardingScreen.tsx | 273 ++++++++++---- .../playlist/PlaylistBottomSheet.tsx | 1 - .../playlist/PlaylistCurationScreen.tsx | 2 +- src/components/playlist/TrackRow.tsx | 109 ++++-- src/constants/colors.ts | 47 ++- src/constants/layout.ts | 2 +- src/store/playerStore.ts | 38 +- src/utils/trackVisuals.ts | 44 +++ tailwind.config.js | 22 +- 14 files changed, 854 insertions(+), 166 deletions(-) create mode 100644 docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md create mode 100644 src/utils/trackVisuals.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 31bdf30..072e27e 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -38,10 +38,10 @@ function CameraTabButton() { - + ); } @@ -53,8 +53,8 @@ export default function TabsLayout() { state.addEvent); const [isActionMenuVisible, setIsActionMenuVisible] = useState(false); + const [isFullPlayerVisible, setIsFullPlayerVisible] = useState(false); if (!currentTrack) { return null; @@ -24,6 +36,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 +67,321 @@ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ; @@ -11,10 +13,73 @@ export function Screen({ children, contentClassName = '' }: ScreenProps) { return ( + + + {children} diff --git a/src/components/home/FeaturedPlaylistCard.tsx b/src/components/home/FeaturedPlaylistCard.tsx index cedffdb..c4ba86a 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/onboarding/OnboardingScreen.tsx b/src/components/onboarding/OnboardingScreen.tsx index 56682d2..9ead5d8 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 bb26ca9..25a1e5b 100644 --- a/src/components/playlist/PlaylistBottomSheet.tsx +++ b/src/components/playlist/PlaylistBottomSheet.tsx @@ -17,7 +17,6 @@ export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSh contentContainerStyle={{ minHeight: height + COLLAPSED_TOP, paddingTop: COLLAPSED_TOP }} scrollEventThrottle={16} showsVerticalScrollIndicator={false} - stickyHeaderIndices={stickyHeader ? [0] : undefined} > {stickyHeader ? ( [ diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index a406daf..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, diff --git a/src/components/playlist/TrackRow.tsx b/src/components/playlist/TrackRow.tsx index 603f801..d10df4c 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,84 @@ 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/constants/colors.ts b/src/constants/colors.ts index 50a114b..18c018c 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -1,24 +1,49 @@ export const colors = { + brand: { + pulseMagenta: '#D718F1', + electricViolet: '#4F2AEC', + signalPurple: '#872BA8', + limeWave: '#B7E628', + deepIndigo: '#3B11C4', + }, background: { - primary: '#050916', - secondary: '#090E1B', - deepPurple: '#160F27', + primary: '#050313', + secondary: '#0A0624', + deepPurple: '#170738', + gradient: ['#050313', '#12052F', '#2B0A69', '#14052E', '#050313'], + aurora: [ + 'rgba(215,24,241,0)', + 'rgba(215,24,241,0.24)', + 'rgba(79,42,236,0.18)', + 'rgba(183,230,40,0.08)', + 'rgba(59,17,196,0)', + ], }, surface: { - card: '#080D18', - chip: '#0E1E3A', - player: '#45343D', - tab: 'rgba(10, 16, 30, 0.94)', + card: '#100828', + cardElevated: '#170D35', + chip: '#20104A', + chipSelected: '#4F2AEC', + player: '#170738', + tab: 'rgba(10, 6, 36, 0.94)', + glass: 'rgba(255,255,255,0.1)', }, border: { - chip: '#364283', + subtle: 'rgba(255,255,255,0.12)', + chip: '#5E45B8', + focus: '#B7E628', }, accent: { - purple: '#7A2CFF', - gold: '#B1913A', + purple: '#4F2AEC', + magenta: '#D718F1', + plum: '#872BA8', + lime: '#B7E628', + indigo: '#3B11C4', }, text: { primary: '#FFFFFF', - secondary: '#ACACAC', + secondary: '#C7BEE8', + 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/store/playerStore.ts b/src/store/playerStore.ts index 68bdb7f..faaf716 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -8,19 +8,53 @@ type PlayerState = { currentTrack?: Track; isPlaying: boolean; playlistId?: string; + queue: Track[]; source: PlayerSource; - setTrack: (track: Track, playlistId?: string) => void; + playNext: () => void; + playPrevious: () => void; + setTrack: (track: Track, playlistId?: string, queue?: Track[]) => void; toggle: () => void; }; export const usePlayerStore = create((set) => ({ isPlaying: false, + queue: [], source: 'none', - setTrack: (track, playlistId) => + 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, + }; + }), + 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/utils/trackVisuals.ts b/src/utils/trackVisuals.ts new file mode 100644 index 0000000..bcee0c1 --- /dev/null +++ b/src/utils/trackVisuals.ts @@ -0,0 +1,44 @@ +import { Track } from '@/types/domain'; + +const fallbackPalette = [ + '#D718F1', + '#4F2AEC', + '#872BA8', + '#B7E628', + '#3B11C4', + '#170D35', +]; + +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..768f734 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,13 +7,21 @@ module.exports = { extend: { colors: { soundlog: { - bg: '#050916', - card: '#080D18', - chip: '#0E1E3A', - border: '#364283', - purple: '#7A2CFF', - gold: '#B1913A', - player: '#45343D', + bg: '#050313', + bg2: '#0A0624', + card: '#100828', + elevated: '#170D35', + chip: '#20104A', + selected: '#4F2AEC', + border: '#5E45B8', + focus: '#B7E628', + magenta: '#D718F1', + purple: '#4F2AEC', + plum: '#872BA8', + lime: '#B7E628', + indigo: '#3B11C4', + player: '#170738', + inverse: '#090515', }, }, }, From f5774145e240526355b927cc89145aa458d34c77 Mon Sep 17 00:00:00 2001 From: Su Date: Sat, 6 Jun 2026 19:50:37 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 21 ++++- docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md | 50 ++++------- src/components/Chip.tsx | 86 ++++--------------- src/components/Screen.tsx | 72 ---------------- src/components/home/FeaturedPlaylistCard.tsx | 4 +- src/components/home/HomeHeader.tsx | 2 +- .../recap-share/RecapCaptureFrame.tsx | 6 +- src/constants/colors.ts | 43 ++++------ src/providers/AppProviders.tsx | 3 +- src/utils/trackVisuals.ts | 12 +-- tailwind.config.js | 23 +++-- 11 files changed, 96 insertions(+), 226 deletions(-) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 072e27e..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() { - + + + ); } @@ -62,10 +69,16 @@ export default function TabsLayout() { paddingBottom: Math.max(insets.bottom, 12), paddingTop: 10, borderTopWidth: 1, - borderTopColor: colors.border.subtle, + borderTopColor: 'rgba(255,255,255,0.24)', backgroundColor: colors.surface.tab, borderTopLeftRadius: 20, borderTopRightRadius: 20, + boxShadow: '0 -18px 42px rgba(0,0,0,0.34)', + shadowColor: '#000', + shadowOffset: { height: -14, width: 0 }, + shadowOpacity: 0.34, + shadowRadius: 24, + ...(Platform.OS === 'web' ? webGlassTabBarStyle : {}), }, }} > diff --git a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md index de37588..877a607 100644 --- a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md +++ b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md @@ -2,44 +2,32 @@ ## Palette -The Soundlog palette is built from the reference colors: +Soundlog keeps the original dark navy and muted violet UI, with one new highlight color: | Token | Hex | Role | | --- | --- | --- | -| `brand.pulseMagenta` | `#D718F1` | Personal taste, emotional highlights, everyday music energy | -| `brand.electricViolet` | `#4F2AEC` | Selected states, primary brand surfaces, active chips | -| `brand.signalPurple` | `#872BA8` | Supporting depth, recap and memory moments | -| `brand.limeWave` | `#B7E628` | High-priority action, travel mode, focus borders | -| `brand.deepIndigo` | `#3B11C4` | Deep anchors, player and navigation depth | +| `background.primary` | `#070B1F` | App background | +| `surface.card` | `#080D18` | Standard cards | +| `surface.chip` | `#0E1E3A` | Default chips and compact controls | +| `surface.chipSelected` | `#243A75` | Selected chips | +| `accent.purple` | `#7A2CFF` | Existing Soundlog purple accent | +| `accent.lime` | `#B7E628` | High-priority action and active focus only | -## Color Roles +## Color Rules -Use role tokens from `src/constants/colors.ts` before adding screen-local colors. - -- Backgrounds use near-black violet tones so the bright palette feels musical instead of flat. -- Surfaces use glassy purple cards and chips with white borders at low opacity. -- Active selections use electric violet with lime focus where the action should feel immediate. -- Primary CTAs and travel-state highlights use lime with `text.inverse`. -- Magenta is reserved for taste, mood, and expressive music moments. +- Use dark navy surfaces as the default visual language. +- Use fluorescent lime sparingly: camera action, active tab, selected/focused border, and Travel mode. +- Do not use the full reference palette across chips or cards. +- Keep chips compact and calm: default navy, selected muted blue, lime only as the selection border. +- Avoid adding new bright hex values inside components unless the color comes from content data. ## Tailwind Tokens -NativeWind tokens live under `soundlog` in `tailwind.config.js`. - - `bg-soundlog-bg`: app background - `bg-soundlog-card`: standard cards -- `bg-soundlog-elevated`: emphasized panels -- `bg-soundlog-chip`: unselected chips and compact controls -- `bg-soundlog-selected`: selected chips and segmented controls -- `bg-soundlog-lime`: high-priority actions -- `border-soundlog-border`: standard purple border -- `border-soundlog-focus`: focus or active emphasis -- `text-soundlog-inverse`: text on lime or bright selected surfaces - -## Component Rules - -- Keep the camera capture action lime so it is recognizable across tabs. -- Keep tab active state lime and inactive state muted white. -- Chips should stay compact, rounded, and role-based: selected violet, unselected purple. -- Do not use the five source colors evenly on every screen. Each screen should have one dominant role and one accent. -- Avoid adding new hex values in components unless the value is data-driven, such as track artwork fallback colors. +- `bg-soundlog-elevated`: emphasized dark panels +- `bg-soundlog-chip`: unselected chips +- `bg-soundlog-selected`: selected chips +- `bg-soundlog-lime`: high-priority action only +- `border-soundlog-border`: standard chip border +- `border-soundlog-focus`: active/focused lime border diff --git a/src/components/Chip.tsx b/src/components/Chip.tsx index 72d13c7..eae0afe 100644 --- a/src/components/Chip.tsx +++ b/src/components/Chip.tsx @@ -1,89 +1,35 @@ -import { LinearGradient } from 'expo-linear-gradient'; import { Pressable } from 'react-native'; import { AppText } from '@/components/AppText'; -import { colors } from '@/constants/colors'; type ChipProps = { label: string; + size?: 'default' | 'small'; selected?: boolean; onPress?: () => void; }; -const chipTones = [ - { - active: [colors.brand.pulseMagenta, colors.brand.electricViolet], - border: 'rgba(215,24,241,0.72)', - inactive: ['rgba(215,24,241,0.32)', 'rgba(135,43,168,0.22)'], - text: '#FDE7FF', - }, - { - active: [colors.brand.electricViolet, colors.brand.deepIndigo], - border: 'rgba(79,42,236,0.76)', - inactive: ['rgba(79,42,236,0.34)', 'rgba(59,17,196,0.24)'], - text: '#ECE7FF', - }, - { - active: [colors.brand.signalPurple, colors.brand.pulseMagenta], - border: 'rgba(135,43,168,0.76)', - inactive: ['rgba(135,43,168,0.34)', 'rgba(215,24,241,0.2)'], - text: '#F7E6FF', - }, - { - active: [colors.brand.limeWave, '#D9FF5A'], - border: 'rgba(183,230,40,0.8)', - inactive: ['rgba(183,230,40,0.28)', 'rgba(79,42,236,0.16)'], - text: '#F5FFD0', - }, - { - active: [colors.brand.deepIndigo, colors.brand.electricViolet], - border: 'rgba(59,17,196,0.82)', - inactive: ['rgba(59,17,196,0.36)', 'rgba(79,42,236,0.2)'], - text: '#E8E2FF', - }, -] as const; - -function getChipTone(label: string) { - const seed = label.split('').reduce((hash, char) => { - return (hash * 31 + char.charCodeAt(0)) % 997; - }, 0); - - return chipTones[seed % chipTones.length]; -} - -export function Chip({ label, onPress, selected = false }: ChipProps) { - const tone = getChipTone(label); - const gradientColors = selected ? tone.active : tone.inactive; +export function Chip({ label, onPress, selected = false, size = 'default' }: ChipProps) { + const isSmall = size === 'small'; return ( - - - {label} - - + {label} + ); } diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx index 4e920ca..51482d7 100644 --- a/src/components/Screen.tsx +++ b/src/components/Screen.tsx @@ -1,10 +1,7 @@ -import { LinearGradient } from 'expo-linear-gradient'; import { PropsWithChildren } from 'react'; import { View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { colors } from '@/constants/colors'; - type ScreenProps = PropsWithChildren<{ contentClassName?: string; }>; @@ -12,75 +9,6 @@ type ScreenProps = PropsWithChildren<{ export function Screen({ children, contentClassName = '' }: ScreenProps) { return ( - - - - {children} ); diff --git a/src/components/home/FeaturedPlaylistCard.tsx b/src/components/home/FeaturedPlaylistCard.tsx index c4ba86a..5177bb1 100644 --- a/src/components/home/FeaturedPlaylistCard.tsx +++ b/src/components/home/FeaturedPlaylistCard.tsx @@ -17,8 +17,8 @@ export function FeaturedPlaylistCard({ playlist }: FeaturedPlaylistCardProps) { > - - + + {playlist.regionName} {playlist.description} diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx index a16798f..e0b8eee 100644 --- a/src/components/home/HomeHeader.tsx +++ b/src/components/home/HomeHeader.tsx @@ -32,7 +32,7 @@ const musicModeOptions: Array<{ value: MusicRecommendationMode; }> = [ { - accent: colors.accent.magenta, + accent: colors.accent.blue, label: 'Everyday', value: 'everyday', }, 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 18c018c..4509ee2 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -1,48 +1,43 @@ export const colors = { brand: { - pulseMagenta: '#D718F1', - electricViolet: '#4F2AEC', - signalPurple: '#872BA8', limeWave: '#B7E628', - deepIndigo: '#3B11C4', }, background: { - primary: '#050313', - secondary: '#0A0624', + primary: '#070B1F', + secondary: '#0B102A', deepPurple: '#170738', - gradient: ['#050313', '#12052F', '#2B0A69', '#14052E', '#050313'], + gradient: ['#070B1F', '#070B1F', '#070B1F', '#070B1F', '#070B1F'], aurora: [ - 'rgba(215,24,241,0)', - 'rgba(215,24,241,0.24)', - 'rgba(79,42,236,0.18)', - 'rgba(183,230,40,0.08)', - 'rgba(59,17,196,0)', + '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: '#100828', - cardElevated: '#170D35', - chip: '#20104A', - chipSelected: '#4F2AEC', - player: '#170738', - tab: 'rgba(10, 6, 36, 0.94)', + card: '#080D18', + cardElevated: '#090E1B', + chip: '#171B2A', + chipSelected: '#B7E628', + player: '#45343D', + tab: 'rgba(10, 16, 30, 0.58)', glass: 'rgba(255,255,255,0.1)', }, border: { subtle: 'rgba(255,255,255,0.12)', - chip: '#5E45B8', + chip: '#364283', focus: '#B7E628', }, accent: { - purple: '#4F2AEC', - magenta: '#D718F1', - plum: '#872BA8', + blue: '#6EA8FF', + purple: '#7A2CFF', + gold: '#B1913A', lime: '#B7E628', - indigo: '#3B11C4', }, text: { primary: '#FFFFFF', - secondary: '#C7BEE8', + secondary: '#ACACAC', muted: 'rgba(255,255,255,0.58)', inverse: '#090515', }, 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/utils/trackVisuals.ts b/src/utils/trackVisuals.ts index bcee0c1..591d9f1 100644 --- a/src/utils/trackVisuals.ts +++ b/src/utils/trackVisuals.ts @@ -1,12 +1,12 @@ import { Track } from '@/types/domain'; const fallbackPalette = [ - '#D718F1', - '#4F2AEC', - '#872BA8', - '#B7E628', - '#3B11C4', - '#170D35', + '#243A75', + '#20146F', + '#2D6A72', + '#45536B', + '#7A2CFF', + '#1D7F8C', ]; function hashString(value: string) { diff --git a/tailwind.config.js b/tailwind.config.js index 768f734..9b36f0f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,20 +7,19 @@ module.exports = { extend: { colors: { soundlog: { - bg: '#050313', - bg2: '#0A0624', - card: '#100828', - elevated: '#170D35', - chip: '#20104A', - selected: '#4F2AEC', - border: '#5E45B8', + bg: '#070B1F', + bg2: '#0B102A', + card: '#080D18', + elevated: '#090E1B', + chip: '#171B2A', + selected: '#B7E628', + border: '#364283', focus: '#B7E628', - magenta: '#D718F1', - purple: '#4F2AEC', - plum: '#872BA8', + blue: '#6EA8FF', + purple: '#7A2CFF', + gold: '#B1913A', lime: '#B7E628', - indigo: '#3B11C4', - player: '#170738', + player: '#45343D', inverse: '#090515', }, }, From 7541bc99b08a6a6fb4cbf4e01f94061991ac903d Mon Sep 17 00:00:00 2001 From: Su Date: Sat, 6 Jun 2026 20:08:29 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=ED=94=8C=EB=A0=88=EC=9D=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84=20=ED=88=AC=EB=AA=85?= =?UTF-8?q?=EB=8F=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MiniPlayer.tsx | 45 ++++++++++++---------------- src/components/playlist/TrackRow.tsx | 12 ++------ 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 78d818e..ec49a27 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -2,7 +2,7 @@ import { Feather } from '@expo/vector-icons'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useState } from 'react'; -import { Modal, 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'; @@ -14,6 +14,11 @@ 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 { @@ -85,23 +90,14 @@ export function MiniPlayer() { {currentTrack.albumImageUrl ? ( ) : ( - + )} ); @@ -138,23 +134,14 @@ export function MiniPlayer() { {currentTrack.albumImageUrl ? ( ) : ( - + )} @@ -168,14 +155,20 @@ export function MiniPlayer() { return ( <> onPress(track)} > {track.albumImageUrl ? ( ) : ( - + )}