From 1b26bc28dd73b4b125a3de2dd71bfe5c32037730 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 14 Jul 2026 17:42:05 +0900 Subject: [PATCH] =?UTF-8?q?fix(live):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=A3=B8=20=EC=A0=84=EC=97=AD=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppState.swift | 47 ++++++ SodaLive/Sources/ContentView.swift | 27 ++++ .../Sources/Live/Room/V2/LiveRoomViewV2.swift | 5 +- SodaLive/Sources/V2/Main/MainView.swift | 65 +------- .../plan-task.md | 140 ++++++++++++++++++ .../prd.md | 74 +++++++++ 6 files changed, 295 insertions(+), 63 deletions(-) create mode 100644 docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/plan-task.md create mode 100644 docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index 4f126e82..e7efdea0 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -38,9 +38,25 @@ class AppState: ObservableObject { if isShowPlayer { ContentPlayManager.shared.stopAudio() ContentPlayerPlayManager.shared.resetPlayer() + return + } + + guard let pendingExternalNavigationAction else { + return + } + + self.pendingExternalNavigationAction = nil + pendingExternalNavigationCancelAction = nil + DispatchQueue.main.async { + pendingExternalNavigationAction() } } } + + @Published private(set) var isShowLeaveLiveNavigationDialog = false + + private var pendingExternalNavigationAction: (() -> Void)? + private var pendingExternalNavigationCancelAction: (() -> Void)? @Published var isShowNotificationSettingsDialog = false @@ -148,6 +164,37 @@ class AppState: ObservableObject { } } + func requestExternalNavigation( + navigationAction: @escaping () -> Void, + cancelAction: @escaping () -> Void + ) { + guard isShowPlayer else { + navigationAction() + return + } + + pendingExternalNavigationAction = navigationAction + pendingExternalNavigationCancelAction = cancelAction + isShowLeaveLiveNavigationDialog = true + } + + func confirmExternalNavigation() { + guard pendingExternalNavigationAction != nil else { + isShowLeaveLiveNavigationDialog = false + return + } + + isShowLeaveLiveNavigationDialog = false + NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil) + } + + func cancelExternalNavigation() { + isShowLeaveLiveNavigationDialog = false + pendingExternalNavigationAction = nil + pendingExternalNavigationCancelAction?() + pendingExternalNavigationCancelAction = nil + } + func consumePendingDeepLinkAction() -> AppDeepLinkAction? { let action = pendingDeepLinkAction pendingDeepLinkAction = nil diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 96309b44..89fe83b2 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -73,6 +73,33 @@ struct ContentView: View { } } } + .overlay { + ZStack { + if appState.isShowPlayer { + LiveRoomViewV2() + } + + if appState.isShowLeaveLiveNavigationDialog { + leaveLiveNavigationDialog + } + } + } + } + + private var leaveLiveNavigationDialog: some View { + SodaV2ActionModal( + title: I18n.Common.alertTitle, + message: I18n.LiveRoom.leaveLiveForNavigationDesc, + button1: SodaV2ActionModalButton( + label: I18n.Common.confirm, + action: appState.confirmExternalNavigation + ), + button2: SodaV2ActionModalButton( + label: I18n.Common.cancel, + action: appState.cancelExternalNavigation + ), + onDimmedTap: appState.cancelExternalNavigation + ) } } diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index 7d413c49..ecfb2356 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -462,7 +462,6 @@ struct LiveRoomViewV2: View { } ) .padding(.top, isV2VCaptionVisible ? -13.3 : 0) - .padding(.bottom, 10) } if viewModel.isShowingNewChat { @@ -1004,7 +1003,6 @@ struct LiveRoomViewV2: View { viewModel.setMute(true) shouldRestoreMicMuteAfterCapture = true } - .ignoresSafeArea(.keyboard) .edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init()) .sheet( isPresented: $viewModel.isShowShareView, @@ -1113,6 +1111,8 @@ struct LiveRoomViewV2: View { } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() } private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat { @@ -1198,6 +1198,7 @@ private final class ScreenCaptureSecureHostingController: UIViewC hostingController = UIHostingController(rootView: rootView) self.isSecureModeEnabled = isSecureModeEnabled super.init(nibName: nil, bundle: nil) + hostingController.safeAreaRegions = .container } required init?(coder: NSCoder) { diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 53064f00..fe998d6c 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -25,9 +25,6 @@ struct MainView: View { @State private var isShowAuthView = false @State private var isShowAuthConfirmView = false @State private var pendingAction: (() -> Void)? = nil - @State private var isShowLeaveLiveNavigationDialog = false - @State private var pendingExternalNavigationAction: (() -> Void)? = nil - @State private var pendingExternalNavigationCancelAction: (() -> Void)? = nil @State private var payload = Payload() var body: some View { @@ -106,14 +103,6 @@ struct MainView: View { if isShowPlayer { ContentPlayerView(isShowing: $isShowPlayer, playlist: []) } - - if appState.isShowPlayer { - LiveRoomViewV2() - } - - if isShowLeaveLiveNavigationDialog { - leaveLiveNavigationDialog - } } .fullScreenCover(isPresented: $isShowAuthView) { authView @@ -124,17 +113,6 @@ struct MainView: View { .valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) } .valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) } .valueChanged(value: appState.pendingMainChatFilter) { handlePendingMainChatFilter($0) } - .valueChanged(value: appState.isShowPlayer) { isShowPlayer in - guard !isShowPlayer, - let pendingExternalNavigationAction = pendingExternalNavigationAction else { - return - } - self.pendingExternalNavigationAction = nil - self.pendingExternalNavigationCancelAction = nil - DispatchQueue.main.async { - pendingExternalNavigationAction() - } - } .onAppear { if appState.pushMessageId > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -306,22 +284,6 @@ struct MainView: View { pendingAction = nil } - private var leaveLiveNavigationDialog: some View { - SodaV2ActionModal( - title: I18n.Common.alertTitle, - message: I18n.LiveRoom.leaveLiveForNavigationDesc, - button1: SodaV2ActionModalButton( - label: I18n.Common.confirm, - action: confirmExternalNavigation - ), - button2: SodaV2ActionModalButton( - label: I18n.Common.cancel, - action: cancelExternalNavigation - ), - onDimmedTap: cancelExternalNavigation - ) - } - private var authView: some View { BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) .onConfirm { _ in true } @@ -452,13 +414,10 @@ struct MainView: View { cancelAction: @escaping () -> Void ) { guard value > 0 else { return } - if appState.isShowPlayer { - pendingExternalNavigationAction = navigationAction - pendingExternalNavigationCancelAction = cancelAction - isShowLeaveLiveNavigationDialog = true - return - } - navigationAction() + appState.requestExternalNavigation( + navigationAction: navigationAction, + cancelAction: cancelAction + ) } private func handleHomeCanChargeTap() { @@ -692,22 +651,6 @@ struct MainView: View { AppState.shared.setAppStep(step: .contentViewSettings) } - private func confirmExternalNavigation() { - guard pendingExternalNavigationAction != nil else { - isShowLeaveLiveNavigationDialog = false - return - } - isShowLeaveLiveNavigationDialog = false - NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil) - } - - private func cancelExternalNavigation() { - isShowLeaveLiveNavigationDialog = false - pendingExternalNavigationAction = nil - pendingExternalNavigationCancelAction?() - pendingExternalNavigationCancelAction = nil - } - private func pushTokenUpdate() { let pushToken = UserDefaults.string(forKey: .pushToken) if !pushToken.trimmingCharacters(in: .whitespaces).isEmpty { diff --git a/docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/plan-task.md b/docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/plan-task.md new file mode 100644 index 00000000..88e04e57 --- /dev/null +++ b/docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/plan-task.md @@ -0,0 +1,140 @@ +# 라이브룸 전역 표시와 키보드 레이아웃 수정 계획 + +## 기준 문서 + +- PRD: `docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md` +- 코드 스타일: `docs/agent-guides/code-style.md` +- 빌드/검증: `docs/agent-guides/build-test-verification.md` +- 기존 외부 이동 확인 기록: `docs/plan-task/20260306_라이브룸외부이동확인다이얼로그.md` + +## 구현 원칙 + +- `enterRoom` API와 성공 처리 대신 라이브룸의 렌더링 계층을 수정한다. +- 전역 라이브룸은 한 곳에서만 생성한다. +- 표시 계층 이동으로 가려질 수 있는 기존 외부 이동 확인 다이얼로그도 같은 최상위 계층으로 옮긴다. +- 키보드 처리 방식은 새로 만들지 않고, 현재 수동 offset과 충돌하는 safe area 적용 위치만 바로잡는다. + +### Phase 1: 라이브룸 전역 표시 + +- [x] **Task 1.1: 라이브룸 표시 지점을 앱 최상위로 이동** + - 대상 파일: + - 수정: `SodaLive/Sources/ContentView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 작업 내용: + - `ContentView`의 `NavigationStack` 최상위에 전역 `ZStack` overlay를 구성한다. + - `AppState.shared.isShowPlayer`가 `true`이면 `NavigationStack` 위에 `LiveRoomViewV2`를 표시한다. + - `MainView` 내부의 기존 `LiveRoomViewV2` 표시 분기를 제거해 중복 생성을 방지한다. + - 검증 기준: + - 실행 명령: `rg -n "LiveRoomViewV2\\(" SodaLive/Sources/ContentView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 실제 표시 지점은 `ContentView` 한 곳에서만 확인된다. + +- [x] **Task 1.2: 외부 이동 확인 상태와 다이얼로그를 전역 계층으로 이동** + - 대상 파일: + - 수정: `SodaLive/Sources/App/AppState.swift` + - 수정: `SodaLive/Sources/ContentView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 작업 내용: + - pending navigation action과 cancel action, 다이얼로그 표시 상태를 `AppState`에서 관리한다. + - `MainView`의 외부 이동 요청은 `AppState`에 위임한다. + - `ContentView`에서 기존 문구와 `SodaV2ActionModal`을 사용해 라이브룸 위에 확인 다이얼로그를 표시한다. + - 라이브 종료 후 pending action 실행 및 취소 시 상태 정리 의미를 유지한다. + - 검증 기준: + - 실행 명령: `rg -n "isShowLeaveLiveNavigationDialog|requestExternalNavigation|confirmExternalNavigation|cancelExternalNavigation|requestLiveRoomQuitForExternalNavigation" SodaLive/Sources/App/AppState.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 상태/처리는 `AppState`, 표시는 `ContentView`, 요청 위임은 `MainView`에서 확인된다. + +### Phase 2: 라이브 채팅 키보드 레이아웃 수정 + +- [x] **Task 2.1: 키보드 safe area 무시 위치 보정 (초기 보정, Phase 4에서 대체)** + - 대상 파일: + - 수정: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` + - 작업 내용: + - 내부 hosted content에 적용된 `.ignoresSafeArea(.keyboard)`를 제거한다. + - `ScreenCaptureSecureContainer` 바깥에 `.ignoresSafeArea(.keyboard)`를 적용한다. + - 기존 `appliedKeyboardHeight`와 채팅 입력 offset은 유지한다. + - 검증 기준: + - 실행 명령: `rg -n "ScreenCaptureSecureContainer|ignoresSafeArea\\(\\.keyboard\\)|offset\\(y: -appliedKeyboardHeight\\)" SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` + - 기대 결과: outer container가 키보드 safe area를 무시하고 수동 offset은 한 번만 적용된다. + +### Phase 3: 검증 + +- [x] **Task 3.1: 변경 범위 및 정적 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/ContentView.swift` + - 확인: `SodaLive/Sources/V2/Main/MainView.swift` + - 확인: `SodaLive/Sources/App/AppState.swift` + - 확인: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` + - 작업 내용: + - 라이브룸 단일 표시, 외부 이동 확인 흐름, 키보드 modifier 위치를 검색한다. + - `git diff --check`로 whitespace 오류를 확인한다. + - 검증 기준: + - 기대 결과: PRD 범위를 벗어난 소스 변경이 없고 정적 검사 오류가 없다. + +- [x] **Task 3.2: Debug 빌드 검증** + - 대상 파일: + - 확인: `SodaLive.xcworkspace` + - 작업 내용: + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행한다. + - 검증 기준: + - 기대 결과: `** BUILD SUCCEEDED **`. + +- [ ] **Task 3.3: 수동 동작 검증** + - 대상 화면: + - 크리에이터 채널의 즉시 라이브 생성 화면 + - 라이브룸 채팅 화면 + - 라이브 중 푸시/딥링크 외부 이동 확인 화면 + - 작업 내용: + - 즉시 라이브 생성 후 현재 화면 위에 라이브룸이 보이는지 확인한다. + - 키보드 표시 시 입력창이 키보드 바로 위에 있고 큰 빈 공간이 없는지 확인한다. + - 외부 이동 확인/취소 흐름을 확인한다. + - 검증 기준: + - 기대 결과: PRD Success Criteria를 충족한다. + +### Phase 4: 사용자 재검증 후 보정 + +- [x] **Task 4.1: 보안 컨테이너를 화면 전체로 확장** + - 대상 파일: + - 수정: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` + - 작업 내용: + - `ScreenCaptureSecureContainer`에 무한 최대 frame을 부여한다. + - 컨테이너 바깥에서 모든 safe area를 무시해 UIKit 컨테이너 경계가 전체 화면을 차지하도록 한다. + - 검증 기준: + - 라이브룸 배경이 상태 표시줄과 홈 인디케이터 영역까지 덮는다. + +- [x] **Task 4.2: 내부 UIHostingController의 키보드 자동 회피 제거** + - 대상 파일: + - 수정: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` + - 작업 내용: + - `UIHostingController.safeAreaRegions`를 `.container`로 제한한다. + - 일반 container safe area는 내부 UI에 유지하고 keyboard safe area만 제외한다. + - 기존 `appliedKeyboardHeight` offset은 유지한다. + - 검증 기준: + - 키보드 표시 시 내부 호스팅 크기 축소와 수동 offset이 중복되지 않는다. + +### Phase 5: 재검증 + +- [x] **Task 5.1: 정적 검사 및 Debug 빌드** + - 대상 파일: + - 확인: `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift` + - 검증 기준: + - `git diff --check`가 성공한다. + - `SodaLive-dev` Debug 빌드가 `** BUILD SUCCEEDED **`로 완료된다. + +- [ ] **Task 5.2: 수정본 수동 동작 검증** + - 대상 화면: + - 전역 라이브룸 화면 + - 라이브룸 채팅 입력 화면 + - 검증 기준: + - 라이브룸이 화면 전체를 덮는다. + - 키보드 표시 시 입력창이 키보드 바로 위에 위치하고 큰 빈 공간이 없다. + +## 검증 기록 + +- 2026-07-14: 코드 조사 결과 `LiveRoomViewV2`가 `MainView` 내부에 있어 navigation destination보다 아래에 놓이는 표시 계층 문제와, `ScreenCaptureSecureContainer` 바깥의 시스템 키보드 회피 및 내부 수동 offset이 중복될 수 있는 구조를 확인했다. 구현 및 빌드 검증은 아직 수행하지 않았다. +- 2026-07-14: `LiveRoomViewV2` 표시를 `ContentView`의 `NavigationStack` overlay로 옮기고, 외부 이동 확인 상태와 다이얼로그도 같은 전역 계층으로 이동했다. `MainView`에서는 전역 상태에 요청만 위임하도록 정리했다. +- 2026-07-14: `.ignoresSafeArea(.keyboard)`를 내부 hosted content에서 `ScreenCaptureSecureContainer` 바깥으로 이동해 시스템 키보드 회피와 기존 수동 offset이 중복되지 않도록 했다. +- 2026-07-14: `rg`로 라이브룸 단일 표시 지점, 외부 이동 확인 흐름, 키보드 modifier 위치를 확인했고 `git diff --check`가 오류 없이 완료됐다. +- 2026-07-14: sandbox 환경의 CoreSimulator/XPC 접근 제한으로 최초 빌드가 소스 컴파일 전에 중단됐다. 동일 명령을 허용된 환경에서 `-derivedDataPath /tmp/SodaLiveDerivedData`와 함께 다시 실행했으며, 전체 빌드와 최종 변경 후 증분 빌드 모두 `** BUILD SUCCEEDED **`를 확인했다. +- 2026-07-14: Task 3.3의 실제 즉시 라이브 생성, 채팅 키보드, 라이브 중 외부 이동 확인/취소는 기기 또는 시뮬레이터 수동 QA가 필요해 미실행 상태로 남겼다. +- 2026-07-14: 사용자 재검증에서 라이브룸이 화면 전체를 덮지 않고 키보드 위 빈 공간도 남는 것을 확인했다. 바깥 `ScreenCaptureSecureContainer`와 내부 `UIHostingController`가 별도의 safe area 경계를 가지므로 바깥 modifier 이동만으로는 부족했다. +- 2026-07-14: `ScreenCaptureSecureContainer`를 전체 화면 frame으로 확장하고 모든 바깥 safe area를 무시하도록 보정했다. 내부 `UIHostingController.safeAreaRegions`는 `.container`로 제한해 상태 표시줄/홈 인디케이터 safe area는 유지하면서 키보드 자동 회피만 제외했다. +- 2026-07-14: 보정 후 `git diff --check`가 성공했고, `xcodebuild -workspace SodaLive.xcworkspace -scheme SodaLive-dev -configuration Debug -derivedDataPath /tmp/SodaLiveDerivedData build`에서 `** BUILD SUCCEEDED **`를 확인했다. Task 5.2의 실제 화면 확인은 사용자 수동 재검증이 필요하다. diff --git a/docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md b/docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md new file mode 100644 index 00000000..28b20511 --- /dev/null +++ b/docs/20260714_라이브룸_전역_표시와_키보드_레이아웃_수정/prd.md @@ -0,0 +1,74 @@ +# PRD: 라이브룸 전역 표시와 키보드 레이아웃 수정 + +## 1. Overview + +라이브룸 표시 계층을 메인 페이지 내부에서 앱 최상위 계층으로 옮겨 현재 화면과 무관하게 입장 결과가 보이도록 하고, 라이브 채팅 키보드 표시 시 중복 레이아웃 이동을 제거한다. + +## 2. Problem + +- `LiveViewModel.enterRoom(roomId:)` 성공 후 `AppState.shared.isShowPlayer`가 `true`가 되어도 `LiveRoomViewV2`는 `MainView` 내부에만 배치되어 있다. +- 크리에이터 채널과 라이브 생성 화면은 `ContentView`의 `NavigationStack` destination으로 메인 페이지 위에 표시되므로, 라이브룸 상태가 활성화되어도 현재 destination보다 아래에서 렌더링될 수 있다. +- `LiveRoomViewV2`는 채팅 키보드 높이만큼 콘텐츠를 수동으로 올리지만, 화면 캡처 보호용 `ScreenCaptureSecureContainer`가 SwiftUI와 내부 `UIHostingController` 사이에 경계를 만든다. +- 바깥 SwiftUI 계층의 키보드 safe area만 무시해도 내부 `UIHostingController`의 기본 `safeAreaRegions == .all`에 키보드 영역이 남는다. 내부 콘텐츠가 자동으로 줄어든 뒤 수동 offset까지 적용되어 키보드 위에 불필요한 빈 공간이 생긴다. +- `ScreenCaptureSecureContainer` 자체가 일반 container safe area 안에 배치되면 내부 배경의 `edgesIgnoringSafeArea`가 UIKit 컨테이너 경계를 넘어갈 수 없어 라이브룸이 화면 전체를 덮지 못한다. + +## 3. Goals + +- 어떤 `NavigationStack` destination이 표시 중이어도 라이브룸 입장 성공 시 `LiveRoomViewV2`가 앱 화면 최상위에 표시된다. +- 라이브룸의 보안 컨테이너와 배경이 상단 및 하단 safe area를 포함한 화면 전체를 덮는다. +- 크리에이터 채널에서 즉시 시작 라이브를 생성하면 `enterRoom` 성공 직후 라이브룸이 보인다. +- 라이브룸 표시 계층 변경 후에도 라이브 중 푸시/딥링크 외부 이동 확인 다이얼로그가 라이브룸 위에 표시된다. +- 채팅 키보드가 표시될 때 입력창은 키보드 바로 위에 위치하고, 키보드 높이만큼의 중복 빈 공간이 생기지 않는다. +- 기존 입장 API, Agora 초기화/종료, 채팅 전송, 캡처 보호 동작을 유지한다. + +## 4. Non-Goals + +- `enterRoom`, `getRoomDetail`, 결제 또는 비밀번호 확인 API 흐름을 변경하지 않는다. +- 라이브 생성 후 메인/홈/라이브 데이터 갱신 정책을 변경하지 않는다. +- 라이브룸 UI 디자인이나 채팅 컴포넌트 모양을 변경하지 않는다. +- 키보드 처리 공통 모듈 전체를 리팩터링하지 않는다. +- `Pods/**`, `generated/**`, `build/**`를 수정하지 않는다. + +## 5. Core Requirements + +### 5.1 라이브룸 전역 표시 + +- `LiveRoomViewV2`의 단일 표시 지점을 `MainView` 내부가 아닌 `ContentView`의 `NavigationStack` 최상위 overlay로 이동한다. +- 표시 조건은 기존과 동일하게 `AppState.shared.isShowPlayer`를 사용한다. +- `LiveRoomViewV2`를 중복 생성하지 않는다. +- `LiveViewModel.enterRoom(roomId:)`의 성공 처리와 `AppState.shared.roomId` 설정은 변경하지 않는다. + +### 5.2 외부 이동 확인 보존 + +- 라이브 중 푸시/딥링크 이동 요청의 pending action을 앱 최상위 계층에서 접근 가능한 상태로 관리한다. +- 확인 다이얼로그는 전역 라이브룸보다 위에 표시한다. +- 확인 시 기존과 같이 `.requestLiveRoomQuitForExternalNavigation` 알림으로 라이브 종료를 요청하고, `isShowPlayer == false`가 된 뒤 pending action을 실행한다. +- 취소 시 pending action과 cancel action을 정리하고 라이브룸을 유지한다. + +### 5.3 키보드 레이아웃 + +- 기존 수동 `appliedKeyboardHeight` offset 동작은 유지한다. +- `ScreenCaptureSecureContainer`는 화면 전체 크기로 확장하고 바깥 SwiftUI 계층의 safe area를 무시한다. +- 내부 `UIHostingController.safeAreaRegions`는 `.container`로 제한해 일반 화면 safe area는 전달하되 키보드 safe area는 전달하지 않는다. +- 시스템 키보드 회피와 수동 offset이 동시에 적용되지 않도록 한다. +- 캡처 보호 활성/비활성 상태 모두 같은 키보드 레이아웃 규칙을 사용한다. + +## 6. Technical Constraints + +- 변경은 기존 파일인 `SodaLive/Sources/ContentView.swift`, `SodaLive/Sources/V2/Main/MainView.swift`, `SodaLive/Sources/App/AppState.swift`, `SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift`에 한정한다. +- 기존 `SodaV2ActionModal`과 외부 이동 확인 문구를 재사용한다. +- 신규 사용자 노출 문자열이나 신규 API를 추가하지 않는다. +- 자동화 테스트 번들 타깃이 없는 현재 프로젝트 구성에서는 정적 검색과 `SodaLive-dev` Debug 빌드로 자동 검증하고, 실제 키보드 높이는 수동 QA 항목으로 남긴다. + +## 7. Success Criteria + +- 크리에이터 채널에서 즉시 라이브 생성 후 라이브룸이 현재 destination 위에 표시된다. +- 홈, 라이브, 크리에이터 채널 등 진입 화면과 무관하게 `isShowPlayer == true`이면 전역 라이브룸이 한 번만 표시된다. +- 라이브룸 배경이 상태 표시줄과 홈 인디케이터 영역을 포함한 화면 전체를 덮는다. +- 라이브 중 외부 이동 요청 시 확인 다이얼로그가 라이브룸 위에 표시되고 확인/취소가 기존 의미대로 동작한다. +- 채팅 입력 포커스 시 화면 이동량이 키보드 회피와 중복되지 않으며 입력창 아래에 큰 빈 공간이 생기지 않는다. +- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 성공한다. + +## 8. Open Questions + +- 해당 없음.