From cc10a531918c7f351204ed7853d5babf596a9c29 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 12:18:07 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=8C=EC=95=85=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=20=EB=A7=81=ED=81=AC=EB=A5=BC=20=EB=82=B4=EB=B6=80=20UI?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 18 +--- app/legal/privacy.tsx | 2 +- app/legal/terms.tsx | 2 +- docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md | 10 +- .../MUSIC_CURATION_PAGE_RN_BUILD_DOC.md | 12 +-- docs/frontend/RN_FRONTEND_PLANNING_POINTS.md | 6 +- .../2026-07-05-internal-music-ui-plan.md | 20 ++++ docs/product/SOUNDLOG_APP_PLANNING.md | 4 +- scripts/check-deployed-web.js | 6 ++ scripts/check-server-web-export.js | 22 ++++- src/components/MiniPlayer.tsx | 92 +++++-------------- src/components/library/LibraryScreen.tsx | 21 +---- src/components/library/LibraryTrackRow.tsx | 2 +- .../playlist/PlaylistCurationScreen.tsx | 31 ++----- src/components/playlist/PlaylistHeroInfo.tsx | 4 +- src/components/playlist/TrackActionMenu.tsx | 82 +++-------------- src/components/playlist/TrackRow.tsx | 2 +- src/store/recommendationEventStore.ts | 1 - src/utils/musicPlatformLinks.ts | 84 ----------------- src/utils/trackSanitizer.ts | 54 +---------- 20 files changed, 115 insertions(+), 360 deletions(-) create mode 100644 docs/implementation/2026-07-05-internal-music-ui-plan.md delete mode 100644 src/utils/musicPlatformLinks.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index fe4b248..820e520 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -44,7 +44,6 @@ import { useUserProfileStore } from '@/store/userProfileStore'; import { FeaturedPlaylist, MoodRecommendation, MusicLogItem, TravelMode } from '@/types/domain'; import { requestForegroundLocationWithStatus } from '@/utils/location'; import { getMoodTagsFromFilter } from '@/utils/moodTags'; -import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; const moodFilterToMlMood: Record = { @@ -184,23 +183,8 @@ function HomeContent() { return; } - const externalLink = getTrackExternalLink(item.track); - setTrack(item.track); - - try { - await openMusicPlatformUrl(externalLink); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - trackId: item.track.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); - } catch { - setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - } + setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); }; const handleSelectFeaturedPlaylist = useCallback( async (playlist: FeaturedPlaylist) => { diff --git a/app/legal/privacy.tsx b/app/legal/privacy.tsx index d1244f1..6e8543d 100644 --- a/app/legal/privacy.tsx +++ b/app/legal/privacy.tsx @@ -16,7 +16,7 @@ const privacySections = [ }, { title: '제3자 서비스', - body: '로그인에는 Soundlog 자체 계정이 사용되고, 장소 정보에는 공공 관광 데이터 API가 사용될 수 있습니다. 외부 음악 앱으로 이동하는 경우 해당 서비스의 개인정보 처리방침이 적용됩니다.', + body: '로그인에는 Soundlog 자체 계정이 사용되고, 장소 정보에는 공공 관광 데이터 API가 사용될 수 있습니다. Soundlog는 음악 추천과 기록 UI를 앱 안에서 제공하며, 음원 재생 서비스 계정 정보는 수집하지 않습니다.', }, { title: '보관과 삭제', diff --git a/app/legal/terms.tsx b/app/legal/terms.tsx index c6efba3..b8b729a 100644 --- a/app/legal/terms.tsx +++ b/app/legal/terms.tsx @@ -16,7 +16,7 @@ const termsSections = [ }, { title: '외부 서비스', - body: '외부 음악 앱이나 관광 데이터 제공자의 서비스로 이동하거나 연동되는 경우 해당 서비스의 약관과 정책이 함께 적용될 수 있습니다.', + body: '장소 정보 등 공공 관광 데이터 제공자의 서비스가 사용될 수 있으며, 해당 데이터 제공자의 정책이 함께 적용될 수 있습니다. Soundlog는 앱 안에서 음악 추천과 기록 UI를 제공하며 외부 음원 재생을 보장하지 않습니다.', }, { title: '제한 사항', diff --git a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md index 7572bc1..495674f 100644 --- a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md +++ b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md @@ -11,7 +11,7 @@ Figma 기준 화면은 `390 x 844` 모바일 화면에 맞춰 설계되어 있 - `Music Playlist` 대표 플레이리스트 캐러셀 - `나의 무드에 맞는 음악 추천` 섹션 - `Music Log` 섹션 -- 하단 외부 링크용 미니 플레이어 +- 하단 Soundlog 음악 컨텍스트 패널 - 하단 탭바 및 중앙 카메라 버튼 React Native 구현 시 Figma의 절대 좌표를 그대로 옮기기보다, 모바일 앱에서 안정적으로 동작하도록 **세로 스크롤 콘텐츠 + 가로 스크롤 리스트 + 하단 고정 미니 링크 패널/탭바** 구조로 재설계한다. @@ -26,7 +26,7 @@ React Native 구현 시 Figma의 절대 좌표를 그대로 옮기기보다, 모 1. 현재 위치 기반 추천 플레이리스트를 빠르게 보여준다. 2. 사용자의 상황/무드 필터를 통해 추천 방향을 조정한다. -3. 현재 선택한 음악과 외부 음악 앱 링크를 항상 접근 가능하게 유지한다. +3. 현재 선택한 음악과 Soundlog 자체 저장/기록 액션을 항상 접근 가능하게 유지한다. 4. 여행 순간 저장 액션을 화면 어디서든 명확하게 제공한다. 5. Music Log를 통해 여행 중 쌓이는 음악 기록을 보여준다. @@ -345,7 +345,7 @@ CTA: ### 역할 -현재 선택한 음악과 외부 음악 앱 링크를 화면 하단에서 항상 확인할 수 있게 한다. +현재 선택한 음악과 Soundlog 자체 저장/기록 액션을 화면 하단에서 항상 확인할 수 있게 한다. ### Figma 기준 요소 @@ -391,7 +391,7 @@ type PlayerState = { | ---------------- | --------------------------------------- | | 미니 플레이어 탭 | 풀 플레이어 화면 또는 bottom sheet 열기 | | 이전 | 이전 곡으로 이동하고 외부 링크 열기 | -| 외부 링크 | 현재 곡의 외부 음악 앱 검색 링크 열기 | +| 순간 기록 | 현재 곡을 유지한 채 Soundlog 카메라로 이동 | | 다음 | 다음 곡으로 이동하고 피드백 이벤트 기록 | --- @@ -645,7 +645,7 @@ Figma는 390px 기준이므로 작은 기기와 큰 기기를 모두 고려해 - 칩 터치 영역은 최소 44px 높이를 권장한다. - 카메라 버튼은 명확한 accessibility label을 가진다. -- 미니 플레이어 버튼은 `이전 곡`, `음악 링크 열기`, `다음 곡` label을 제공한다. +- 미니 플레이어 버튼은 `이전 추천 곡 선택`, `선택한 음악 상세 보기`, `다음 추천 곡 선택` label을 제공한다. - 색상만으로 선택 상태를 구분하지 않고, 선택 칩에는 텍스트/테두리 차이를 함께 준다. 예시: diff --git a/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md b/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md index f38cdbc..53e4a4f 100644 --- a/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md +++ b/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md @@ -16,7 +16,7 @@ Figma 기준 화면은 사용자의 현재 위치를 기반으로 추천된 플 1. 현재 위치 또는 선택 장소 기반 플레이리스트를 보여준다. 2. 왜 이 플레이리스트가 추천되었는지 장소 맥락을 전달한다. -3. 곡 리스트를 탐색하고 외부 음악 앱 검색 링크를 바로 열 수 있게 한다. +3. 곡 리스트를 탐색하고 선택한 곡을 Soundlog 안의 현재 여행 음악으로 유지한다. 4. 곡별 더보기, 저장, 좋아요 같은 음악 액션의 진입점을 제공한다. 5. 현재 선택한 음악은 미니 플레이어로 유지한다. @@ -110,7 +110,7 @@ bg-black/40 overlay - 지역명: `Seoul` - 추천 근거: `Based on your location` -- 대표곡 외부 음악 링크 버튼 +- 대표곡 Soundlog 음악 선택 버튼 ### RN 컴포넌트 구조 @@ -209,7 +209,7 @@ type CuratedTrack = { | 액션 | 동작 | | --- | --- | -| Row 탭 | 현재 선택 곡으로 지정하고 외부 음악 링크 열기 | +| Row 탭 | 현재 선택 곡으로 지정하고 Soundlog 음악 패널 표시 | | 더보기 탭 | BottomSheet 메뉴 열기 | | 길게 누르기 | 좋아요/저장 quick action, 후순위 | @@ -219,11 +219,11 @@ type CuratedTrack = { 좋아요 저장하기 이 곡으로 순간 저장 -외부 음악 앱에서 열기 +이 곡으로 순간 저장 이 곡 숨기기 ``` -MVP에서는 `좋아요`, `저장하기`, `외부 음악 앱에서 열기`만 구현해도 충분하다. +MVP에서는 `좋아요`, `저장하기`, `이 곡으로 순간 저장` 흐름만 구현해도 충분하다. --- @@ -231,7 +231,7 @@ MVP에서는 `좋아요`, `저장하기`, `외부 음악 앱에서 열기`만 ### 역할 -현재 선택한 곡과 외부 음악 앱 링크를 하단에서 확인한다. +현재 선택한 곡과 Soundlog 자체 저장/기록 액션을 하단에서 확인한다. ### Figma 기준 diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index e20f9b5..ba8c88e 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -12,11 +12,11 @@ Soundlog는 위치, 음악, 카메라, 여행 로그, Recap을 함께 다루는 ### 2.1 사용자가 여행 중 화면을 오래 보지 않아도 되는 앱 -Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔드는 많은 정보를 보여주는 것보다, 사용자가 최소 조작으로 외부 음악 링크를 열고 순간을 저장할 수 있도록 설계해야 한다. +Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔드는 많은 정보를 보여주는 것보다, 사용자가 최소 조작으로 앱 안에서 음악 맥락을 선택하고 순간을 저장할 수 있도록 설계해야 한다. 핵심 기준은 다음과 같다. -- 홈에서 현재 장소, 추천 플레이리스트, 선택한 음악 링크 상태, 순간 저장 버튼이 즉시 보여야 한다. +- 홈에서 현재 장소, 추천 플레이리스트, 선택한 음악 상태, 순간 저장 버튼이 즉시 보여야 한다. - 여행 중 주요 액션은 1~2탭 안에 끝나야 한다. - 음악 추천 결과가 마음에 들지 않을 때 검색 화면으로 보내지 않고 무드 조정 버튼으로 해결해야 한다. - 카메라 버튼은 항상 접근 가능한 중심 액션으로 유지한다. @@ -200,7 +200,7 @@ MVP에서 미니/풀 링크 패널은 복잡한 음악 앱 수준의 재생 제 - 외부 음악 앱 검색 링크, 다음/이전 곡 이동, 좋아요/저장을 우선 제공한다. - 다음/이전 이동과 외부 링크 열기 이벤트는 추천 피드백으로 수집한다. - 백그라운드 음원 제어, 인앱 음원 제공, 외부 플랫폼 상태 제어는 MVP 범위에서 제외한다. -- 실제 음악 감상은 YouTube Music 또는 Melon 검색 링크로 넘긴다. +- MVP에서는 실제 음원 감상이나 외부 음악 앱 이동을 제공하지 않고, 선택한 곡 정보를 Soundlog 기록 UI에 유지한다. ### 5.7 순간 저장 카메라 diff --git a/docs/implementation/2026-07-05-internal-music-ui-plan.md b/docs/implementation/2026-07-05-internal-music-ui-plan.md new file mode 100644 index 0000000..5fde8cb --- /dev/null +++ b/docs/implementation/2026-07-05-internal-music-ui-plan.md @@ -0,0 +1,20 @@ +# Internal music UI policy + +## Context + +Music actions must stay inside SoundLog. The app does not stream audio and should not send users to YouTube Music, Melon, Spotify, or generated search URLs from the main recommendation, playlist, library, or mini-player flows. + +## UI contract + +- Tapping a recommended track selects it as the current SoundLog music context. +- The mini player shows the selected track as a SoundLog context panel, not as playback or an external link panel. +- The full music panel offers SoundLog-native actions: previous/next recommendation, like, save, and moment capture with the selected track. +- Track action menus should not include external music app actions. +- Server-provided `externalUrl` and `platformUrls` must be stripped from frontend runtime track state. +- Web export and deployed-web checks must fail if YouTube Music URLs or external platform open helpers are bundled. + +## Verification + +- `npm run typecheck` +- `npm run check:server-web-export` +- `npm run check:deployed-web -- https://soundlog.shop` after deployment diff --git a/docs/product/SOUNDLOG_APP_PLANNING.md b/docs/product/SOUNDLOG_APP_PLANNING.md index 7959c96..045c3a1 100644 --- a/docs/product/SOUNDLOG_APP_PLANNING.md +++ b/docs/product/SOUNDLOG_APP_PLANNING.md @@ -53,7 +53,7 @@ Soundlog는 이 문제를 다음 세 가지 관점에서 해결한다. ### 4.2 Eyes-free 여행 몰입 UX -Soundlog는 여행 중 화면 조작을 최소화하는 방향으로 설계된다. 추천된 플레이리스트는 앱 안에서 장소와 곡 맥락을 보여주고, 사용자는 하단 미니 플레이어에서 현재 선택한 곡을 확인한 뒤 외부 음악 앱 검색 링크로 이동한다. Soundlog MVP는 음원을 직접 스트리밍하거나 외부 플랫폼 재생 상태를 제어하지 않는다. +Soundlog는 여행 중 화면 조작을 최소화하는 방향으로 설계된다. 추천된 플레이리스트는 앱 안에서 장소와 곡 맥락을 보여주고, 사용자는 하단 미니 플레이어에서 현재 선택한 곡을 확인한 뒤 좋아요, 저장, 순간 기록 같은 Soundlog 자체 액션으로 이어진다. Soundlog MVP는 음원을 직접 스트리밍하거나 외부 플랫폼 재생 상태를 제어하지 않는다. 추천이 현재 분위기와 맞지 않을 경우 복잡한 검색을 다시 하지 않아도 된다. 사용자는 “더 신나게”, “더 잔잔하게”, “더 감성적으로”, “더 로컬하게” 같은 무드 조정 옵션을 선택해 추천 방향을 즉시 바꿀 수 있다. @@ -108,7 +108,7 @@ Music Log는 여행 중 선택하거나 저장한 음악을 장소와 함께 보 주요 기능은 다음과 같다. - 추천 사유 제공: “광안리 해변 산책과 어울리는 청량한 플레이리스트”처럼 추천 맥락을 짧게 설명한다. -- 외부 음악 링크: 앱 안에서 직접 재생하지 않고 YouTube Music 또는 Melon 검색 링크로 이동한다. +- 음악 컨텍스트 UI: 앱 안에서 직접 재생하거나 외부 음악 앱으로 이동하지 않고, 선택한 곡 정보를 Soundlog의 여행 기록 맥락으로 유지한다. - 무드 조정: 더 신나게, 더 잔잔하게, 더 감성적으로, 더 로컬하게 옵션을 제공한다. - 좋아요/저장: 마음에 드는 곡과 플레이리스트를 저장한다. diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js index 0145cb7..2527ed8 100644 --- a/scripts/check-deployed-web.js +++ b/scripts/check-deployed-web.js @@ -265,6 +265,12 @@ async function verifyBundle() { ['track_skip', 'Deployed bundle must not include fake skip playback events.'], ['spotify-auth', 'Deployed bundle must not include the removed Spotify auth route.'], ['open.spotify.com', 'Deployed bundle must not include Spotify external search URLs.'], + ['music.youtube.com', 'Deployed bundle must not include YouTube Music URLs.'], + ['YouTube Music', 'Deployed bundle must not include YouTube Music action copy.'], + [ + 'openMusicPlatformUrl', + 'Deployed bundle must not include external music platform open helpers.', + ], [ 'playSelectedSpotifyOrFallback', 'Deployed bundle must not include removed Spotify playback helpers.', diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index d3b09fa..efa1413 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -85,17 +85,22 @@ function verifyHonestMusicActionSourceFiles() { ].filter((dir) => fs.existsSync(dir)); const blockedSourceMarkers = [ ['NOW PLAYING', 'User-facing source must not imply in-app streaming playback.'], - ['track_play', 'Frontend source must record track_external_open, not fake play events.'], + ['track_play', 'Frontend source must not record fake play events.'], ['track_pause', 'Frontend source must not include fake pause events.'], ['track_resume', 'Frontend source must not include fake resume events.'], ['track_skip', 'Frontend source must not include fake skip playback events.'], ['spotify-auth', 'Frontend source must not include the removed Spotify auth route.'], ['open.spotify.com', 'Frontend source must not include Spotify external search URLs.'], ['playSelectedSpotifyOrFallback', 'Frontend source must not include Spotify playback helpers.'], + ['openMusicPlatformUrl', 'Frontend source must not open external music platform URLs.'], + ['musicPlatformLinks', 'Frontend source must not import external music platform link helpers.'], + ['music.youtube.com', 'Frontend source must not include YouTube Music URLs.'], + ['YouTube Music', 'Frontend source must not mention YouTube Music actions.'], ]; sourceDirs .flatMap((dir) => readTextFiles(dir)) + .filter(({ filePath }) => !isAllowedMockReferencePath(filePath)) .forEach(({ filePath, text }) => { blockedSourceMarkers.forEach(([marker, message]) => { if (text.includes(marker)) { @@ -242,6 +247,21 @@ function verifyBundle(bundleText) { 'open.spotify.com', 'Server web export must not include Spotify external search URLs.', ); + assertExcludes( + bundleText, + 'music.youtube.com', + 'Server web export must not include YouTube Music URLs.', + ); + assertExcludes( + bundleText, + 'YouTube Music', + 'Server web export must not include YouTube Music action copy.', + ); + assertExcludes( + bundleText, + 'openMusicPlatformUrl', + 'Server web export must not include external music platform open helpers.', + ); } try { diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 71eed69..645434a 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -1,8 +1,9 @@ import { Feather } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useState } from 'react'; -import { ActivityIndicator, Modal, Platform, Pressable, View } from 'react-native'; +import { Modal, Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { libraryApi } from '@/api/libraryApi'; @@ -13,7 +14,6 @@ import { getMiniPlayerBottom } from '@/constants/layout'; import { useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals'; @@ -24,6 +24,7 @@ const webGlassPlayerStyle = { export function MiniPlayer() { const insets = useSafeAreaInsets(); + const router = useRouter(); const { currentTrack, playNext, @@ -34,10 +35,8 @@ export function MiniPlayer() { const { isLiked, isSaved, setLikeState, setSaveState } = useLibraryStore(); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); const [actionMessage, setActionMessage] = useState(); - const [externalMessage, setExternalMessage] = useState(); const [isActionMenuVisible, setIsActionMenuVisible] = useState(false); const [isFullPlayerVisible, setIsFullPlayerVisible] = useState(false); - const [isOpeningExternal, setIsOpeningExternal] = useState(false); if (!currentTrack) { return null; @@ -49,7 +48,6 @@ export function MiniPlayer() { const playerGlow = hexToRgba(keyColor, 0.72); const playerSoftGlow = hexToRgba(keyColor, 0.24); const canSkip = queue.length > 1; - const externalLink = getTrackExternalLink(currentTrack); const handleToggleLike = () => { const context = createRecommendationEventContext(); @@ -74,34 +72,6 @@ export function MiniPlayer() { }), ); }; - const handleOpenTrackExternal = async (track = currentTrack) => { - const link = getTrackExternalLink(track); - - if (!link.url || isOpeningExternal) { - setExternalMessage('이 곡을 열 수 있는 링크를 만들지 못했어요.'); - return; - } - - setIsOpeningExternal(true); - setExternalMessage(undefined); - - try { - await openMusicPlatformUrl(link); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId, - trackId: track.id, - type: 'track_external_open', - value: link.platformId, - }), - ); - } catch { - setExternalMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - } finally { - setIsOpeningExternal(false); - } - }; const handleToggleSave = () => { const context = createRecommendationEventContext(); @@ -131,7 +101,6 @@ export function MiniPlayer() { return; } - setExternalMessage(undefined); playNext(); }; const handleSelectPreviousTrack = () => { @@ -139,9 +108,12 @@ export function MiniPlayer() { return; } - setExternalMessage(undefined); playPrevious(); }; + const handleCaptureMoment = () => { + setIsFullPlayerVisible(false); + router.push('/camera'); + }; const renderCover = (sizeClassName: string, radiusClassName: string) => ( - {externalLink.label} + SoundLog 음악으로 선택됨 @@ -270,18 +242,13 @@ export function MiniPlayer() { void handleOpenTrackExternal()} + onPress={() => setIsFullPlayerVisible(true)} style={{ backgroundColor: playerGlow }} > - {isOpeningExternal ? ( - - ) : ( - - )} + - {externalLink.label} + SoundLog 안에서 현재 여행 음악으로 선택됐어요 @@ -384,18 +351,13 @@ export function MiniPlayer() { void handleOpenTrackExternal()} + onPress={handleCaptureMoment} style={{ backgroundColor: playerGlow }} > - {isOpeningExternal ? ( - - ) : ( - - )} + void handleOpenTrackExternal()} - style={{ opacity: isOpeningExternal ? 0.72 : 1 }} + onPress={handleCaptureMoment} > - {isOpeningExternal ? ( - - ) : ( - - )} + - {externalLink.label} + 이 곡으로 순간 기록 - {externalMessage ? ( - - - {externalMessage} - - - ) : null} + + + 음원을 재생하지 않고 곡 정보와 여행 순간을 SoundLog 안에 기록해요. + + diff --git a/src/components/library/LibraryScreen.tsx b/src/components/library/LibraryScreen.tsx index f385a70..7a6d034 100644 --- a/src/components/library/LibraryScreen.tsx +++ b/src/components/library/LibraryScreen.tsx @@ -12,7 +12,6 @@ import { Screen } from '@/components/Screen'; import { LibraryTrackRecord, useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; type LibraryTab = 'liked' | 'playlists' | 'saved'; @@ -103,26 +102,10 @@ export function LibraryScreen() { setSelectedTab(tab); closeMenu(); }; - const playRecord = async (record: LibraryTrackRecord) => { - const externalLink = getTrackExternalLink(record.track); - + const playRecord = (record: LibraryTrackRecord) => { setActionMessage(undefined); setTrack(record.track, record.playlistId); - - try { - await openMusicPlatformUrl(externalLink); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId: record.playlistId, - trackId: record.track.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); - } catch { - setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - } + setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); }; const toggleSelectedLike = () => { if (!selectedRecord) { diff --git a/src/components/library/LibraryTrackRow.tsx b/src/components/library/LibraryTrackRow.tsx index 4b4be34..fefd644 100644 --- a/src/components/library/LibraryTrackRow.tsx +++ b/src/components/library/LibraryTrackRow.tsx @@ -21,7 +21,7 @@ export function LibraryTrackRow({ return ( { + const selectTrackForSoundlog = (track: Track) => { if (!playlist) { return; } - const externalLink = getTrackExternalLink(track); - setActionMessage(undefined); setTrack(track, playlist.id, playlist.tracks); - - try { - await openMusicPlatformUrl(externalLink); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId: playlist.id, - trackId: track.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); - } catch { - setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - } + setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 순간 기록에 담을 수 있어요.'); }; - const openFirstTrackExternal = () => { + const selectFirstTrack = () => { const firstTrack = playlist?.tracks[0]; if (!firstTrack) { return; } - openTrackExternal(firstTrack); + selectTrackForSoundlog(firstTrack); }; const toggleLiked = () => { @@ -193,7 +176,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro currentTrackId={currentTrack?.id} likedTrackIds={likedTrackIds} onOpenMenu={(track) => setSelectedTrackId(track.id)} - onSelectTrack={openTrackExternal} + onSelectTrack={selectTrackForSoundlog} savedTrackIds={savedTrackIds} tracks={playlist.tracks} /> @@ -213,7 +196,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro {playlist ? ( ) : null} @@ -225,7 +208,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro playlist ? ( ) : undefined diff --git a/src/components/playlist/PlaylistHeroInfo.tsx b/src/components/playlist/PlaylistHeroInfo.tsx index 3796bf4..bf3936b 100644 --- a/src/components/playlist/PlaylistHeroInfo.tsx +++ b/src/components/playlist/PlaylistHeroInfo.tsx @@ -42,14 +42,14 @@ export function PlaylistHeroInfo({ - + diff --git a/src/components/playlist/TrackActionMenu.tsx b/src/components/playlist/TrackActionMenu.tsx index de18dcf..e27b606 100644 --- a/src/components/playlist/TrackActionMenu.tsx +++ b/src/components/playlist/TrackActionMenu.tsx @@ -1,14 +1,9 @@ import { Feather } from '@expo/vector-icons'; import { Image } from 'expo-image'; -import { useMemo, useState } from 'react'; -import { ActivityIndicator, Modal, Pressable, View } from 'react-native'; +import { Modal, Pressable, View } from 'react-native'; -import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; -import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { Track } from '@/types/domain'; -import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; -import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; type TrackActionMenuProps = { actionMessage?: string; @@ -25,22 +20,17 @@ type MenuActionProps = { disabled?: boolean; icon: keyof typeof Feather.glyphMap; label: string; - isLoading?: boolean; onPress: () => void; }; -function MenuAction({ disabled = false, icon, isLoading = false, label, onPress }: MenuActionProps) { +function MenuAction({ disabled = false, icon, label, onPress }: MenuActionProps) { return ( - {isLoading ? ( - - ) : ( - - )} + {label} @@ -58,57 +48,9 @@ export function TrackActionMenu({ track, visible, }: TrackActionMenuProps) { - const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); - const [externalMessage, setExternalMessage] = useState(); - const [isOpeningExternal, setIsOpeningExternal] = useState(false); - const externalLink = useMemo( - () => (track ? getTrackExternalLink(track) : undefined), - [track], - ); - const canOpenExternal = Boolean(externalLink?.url); - const handleClose = () => { - if (isOpeningExternal) { - return; - } - - setExternalMessage(undefined); onClose(); }; - const openExternalUrl = async () => { - if (!track || !externalLink) { - return; - } - - if (!externalLink.url) { - setExternalMessage('이 곡을 열 수 있는 링크를 만들지 못했어요.'); - return; - } - - if (isOpeningExternal) { - return; - } - - setIsOpeningExternal(true); - setExternalMessage(undefined); - - try { - await openMusicPlatformUrl(externalLink); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - trackId: track.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); - onClose(); - } catch { - setExternalMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - } finally { - setIsOpeningExternal(false); - } - }; return ( @@ -152,18 +94,16 @@ export function TrackActionMenu({ onPress={onToggleSave} /> {track ? ( - + + + 곡을 누르면 SoundLog 안에서 현재 여행 음악으로 선택돼요. + + ) : null} - {actionMessage || externalMessage ? ( + {actionMessage ? ( - {actionMessage ?? externalMessage} + {actionMessage} ) : null} diff --git a/src/components/playlist/TrackRow.tsx b/src/components/playlist/TrackRow.tsx index 8755d3f..cbc9279 100644 --- a/src/components/playlist/TrackRow.tsx +++ b/src/components/playlist/TrackRow.tsx @@ -48,7 +48,7 @@ export function TrackRow({ isActive, isLiked, isSaved, onMore, onPress, track }: onPress(track)} diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index adbd815..7610f34 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -5,7 +5,6 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { MusicRecommendationMode, TravelMode } from '@/types/domain'; export type RecommendationEventType = - | 'track_external_open' | 'track_like' | 'track_unlike' | 'track_save' diff --git a/src/utils/musicPlatformLinks.ts b/src/utils/musicPlatformLinks.ts deleted file mode 100644 index 80110b6..0000000 --- a/src/utils/musicPlatformLinks.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Linking } from 'react-native'; - -import { MusicPlatformId, Track } from '@/types/domain'; - -export type TrackExternalLinkResult = { - fallbackUrl?: string; - label: string; - platformId: MusicPlatformId; - url?: string; - usedFallback: boolean; -}; - -function createTrackQuery(track: Track) { - const title = track.title.trim(); - - if (!title) { - return undefined; - } - - const artist = track.artist.trim(); - - return artist ? `${artist} ${title}` : title; -} - -function createYoutubeMusicSearchUrl(query: string) { - return `https://music.youtube.com/search?q=${encodeURIComponent(query)}`; -} - -export function getTrackExternalLink(track: Track): TrackExternalLinkResult { - if (track.externalUrl) { - return { - label: '음악 링크 열기', - platformId: 'none', - url: track.externalUrl, - usedFallback: false, - }; - } - - if (track.platformUrls?.youtubeMusic) { - return { - label: 'YouTube Music에서 열기', - platformId: 'youtubeMusic', - url: track.platformUrls.youtubeMusic, - usedFallback: false, - }; - } - - const query = createTrackQuery(track); - - if (!query) { - return { - label: '음악 링크 열기', - platformId: 'none', - usedFallback: true, - }; - } - - return { - label: 'YouTube Music에서 검색', - platformId: 'youtubeMusic', - url: createYoutubeMusicSearchUrl(query), - usedFallback: true, - }; -} - -export async function openMusicPlatformUrl(target: string | TrackExternalLinkResult) { - const url = typeof target === 'string' ? target : target.url; - const fallbackUrl = typeof target === 'string' ? undefined : target.fallbackUrl; - - if (!url) { - throw new Error('music_platform_url_missing'); - } - - try { - await Linking.openURL(url); - } catch (error) { - if (fallbackUrl && fallbackUrl !== url) { - await Linking.openURL(fallbackUrl); - return; - } - - throw error; - } -} diff --git a/src/utils/trackSanitizer.ts b/src/utils/trackSanitizer.ts index 7253541..8997efc 100644 --- a/src/utils/trackSanitizer.ts +++ b/src/utils/trackSanitizer.ts @@ -1,60 +1,10 @@ import { MoodRecommendation, PlaylistCuration, RecapItem, Track } from '@/types/domain'; -const SUPPORTED_PLATFORM_IDS = ['melon', 'youtubeMusic'] as const; -const SUPPORTED_EXTERNAL_HOSTS = ['music.youtube.com']; - -function sanitizePlatformUrls(platformUrls: Track['platformUrls']) { - if (!platformUrls) { - return undefined; - } - - const nextPlatformUrls: Track['platformUrls'] = {}; - - SUPPORTED_PLATFORM_IDS.forEach((platformId) => { - const url = platformUrls[platformId]; - - if (url) { - nextPlatformUrls[platformId] = url; - } - }); - - return Object.keys(nextPlatformUrls).length > 0 ? nextPlatformUrls : undefined; -} - -function isSupportedExternalUrl(url?: string) { - if (!url) { - return false; - } - - try { - const host = new URL(url).hostname.toLowerCase(); - - return ( - SUPPORTED_EXTERNAL_HOSTS.includes(host) || - host === 'melon.com' || - host.endsWith('.melon.com') - ); - } catch { - return false; - } -} - export function sanitizeTrack(track: Track): Track { - const platformUrls = sanitizePlatformUrls(track.platformUrls); - const externalUrl = isSupportedExternalUrl(track.externalUrl) ? track.externalUrl : undefined; const nextTrack = { ...track }; - if (platformUrls) { - nextTrack.platformUrls = platformUrls; - } else { - delete nextTrack.platformUrls; - } - - if (externalUrl) { - nextTrack.externalUrl = externalUrl; - } else { - delete nextTrack.externalUrl; - } + delete nextTrack.externalUrl; + delete nextTrack.platformUrls; return nextTrack; }