From 503468f713b8e570f42592c5f478c92f29ee469e Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 28 Apr 2026 11:59:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(yandex-ads):=20=ED=99=94=EB=A9=B4=EB=B3=84?= =?UTF-8?q?=20Yandex=20=EA=B4=91=EA=B3=A0=20=EB=B0=B0=EC=B9=98=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/Common/YandexAdSupport.swift | 278 ++++++++++++++++++ .../Detail/ContentDetailPlayView.swift | 163 +++++----- .../Content/Detail/ContentDetailView.swift | 19 +- SodaLive/Sources/Debug/Utils/Constants.swift | 5 + SodaLive/Sources/Live/LiveView.swift | 3 + .../Live/Room/Detail/LiveDetailView.swift | 30 +- SodaLive/Sources/Utils/Constants.swift | 5 + docs/20260428_Yandex광고화면배치구현.md | 105 +++++++ 8 files changed, 512 insertions(+), 96 deletions(-) create mode 100644 SodaLive/Sources/Common/YandexAdSupport.swift create mode 100644 docs/20260428_Yandex광고화면배치구현.md diff --git a/SodaLive/Sources/Common/YandexAdSupport.swift b/SodaLive/Sources/Common/YandexAdSupport.swift new file mode 100644 index 0000000..f7003d7 --- /dev/null +++ b/SodaLive/Sources/Common/YandexAdSupport.swift @@ -0,0 +1,278 @@ +// +// YandexAdSupport.swift +// SodaLive +// +// Created by OpenCode on 2026/04/28. +// + +import SwiftUI +import YandexMobileAds + +enum YandexBannerPlacement { + case liveTab + case liveDetail + case contentDetail +} + +enum YandexInterstitialPlacement { + case contentDetail +} + +enum YandexAdUnitIdProvider { + + static func banner(for placement: YandexBannerPlacement) -> String { + switch placement { + case .liveTab: + YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID + case .liveDetail: + YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID + case .contentDetail: + YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID + } + } + + static func interstitial(for placement: YandexInterstitialPlacement) -> String { + switch placement { + case .contentDetail: + YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID + } + } +} + +struct YandexInlineBannerView: View { + + let placement: YandexBannerPlacement + + var maxHeight: CGFloat = 90 + var horizontalPadding: CGFloat = 13.3 + + @State private var bannerHeight: CGFloat = 0 + @State private var isLoadFailed = false + + var body: some View { + GeometryReader { proxy in + let width = max(proxy.size.width - (horizontalPadding * 2), 1) + let resolvedHeight = isLoadFailed ? 0 : (bannerHeight > 0 ? bannerHeight : maxHeight) + + YandexInlineBannerContainer( + placement: placement, + width: width, + maxHeight: maxHeight, + bannerHeight: $bannerHeight, + isLoadFailed: $isLoadFailed + ) + .frame(width: width, height: resolvedHeight) + .padding(.horizontal, horizontalPadding) + } + .frame(height: isLoadFailed ? 0 : (bannerHeight > 0 ? bannerHeight : maxHeight)) + } +} + +private struct YandexInlineBannerContainer: UIViewRepresentable { + + let placement: YandexBannerPlacement + + let width: CGFloat + let maxHeight: CGFloat + + @Binding var bannerHeight: CGFloat + @Binding var isLoadFailed: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.parent = self + context.coordinator.configureIfNeeded(containerView: uiView) + } + + final class Coordinator: NSObject, BannerAdViewDelegate { + + var parent: YandexInlineBannerContainer + private weak var containerView: UIView? + private var bannerAdView: BannerAdView? + private var currentWidth: CGFloat = 0 + + init(parent: YandexInlineBannerContainer) { + self.parent = parent + } + + func configureIfNeeded(containerView: UIView) { + self.containerView = containerView + + let roundedWidth = parent.width.rounded(.down) + + guard bannerAdView == nil || abs(currentWidth - roundedWidth) > 0.5 else { + return + } + + currentWidth = roundedWidth + + DispatchQueue.main.async { + self.parent.bannerHeight = 0 + self.parent.isLoadFailed = false + } + + bannerAdView?.removeFromSuperview() + + let adSize = BannerAdSize.inline(width: roundedWidth, maxHeight: parent.maxHeight) + let bannerAdView = BannerAdView(adSize: adSize) + bannerAdView.translatesAutoresizingMaskIntoConstraints = false + bannerAdView.delegate = self + + containerView.addSubview(bannerAdView) + + NSLayoutConstraint.activate([ + bannerAdView.topAnchor.constraint(equalTo: containerView.topAnchor), + bannerAdView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + bannerAdView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + bannerAdView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + ]) + + self.bannerAdView = bannerAdView + bannerAdView.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.banner(for: parent.placement))) + } + + func bannerAdViewDidLoad(_ adView: BannerAdView) { + adView.layoutIfNeeded() + + let measuredHeight = adView.bounds.height > 0 ? adView.bounds.height : parent.maxHeight + + DispatchQueue.main.async { + self.parent.bannerHeight = measuredHeight + self.parent.isLoadFailed = false + } + } + + func bannerAdViewDidFailLoading(_ adView: BannerAdView, error: Error) { + DispatchQueue.main.async { + self.parent.bannerHeight = 0 + self.parent.isLoadFailed = true + } + } + + func bannerAdViewDidClick(_ adView: BannerAdView) { + } + + func bannerAdView(_ adView: BannerAdView, didTrackImpression impressionData: ImpressionData?) { + } + } +} + +@MainActor +final class YandexInterstitialAdManager: NSObject { + + static let shared = YandexInterstitialAdManager() + + private var interstitialAd: InterstitialAd? + private var interstitialAdLoader: InterstitialAdLoader? + private var currentPlacement: YandexInterstitialPlacement? + private var pendingAction: (@MainActor () -> Void)? + private var isLoading = false + + func preloadAd(for placement: YandexInterstitialPlacement) { + guard !isLoading else { + return + } + + if currentPlacement == placement, interstitialAd != nil { + return + } + + let loader = InterstitialAdLoader() + interstitialAdLoader = loader + interstitialAd = nil + currentPlacement = placement + isLoading = true + + Task { + do { + let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.interstitial(for: placement))) + guard currentPlacement == placement else { + isLoading = false + return + } + + interstitialAd = loadedAd + } catch { + if currentPlacement == placement { + interstitialAd = nil + } + } + + if currentPlacement == placement { + isLoading = false + } + } + } + + func showAdIfAvailable(for placement: YandexInterstitialPlacement, then action: @escaping @MainActor () -> Void) { + guard let presenter = presentingViewController(), let interstitialAd, currentPlacement == placement else { + action() + preloadAd(for: placement) + return + } + + pendingAction = action + self.interstitialAd = nil + interstitialAd.delegate = self + interstitialAd.show(from: presenter) + } + + private func completePendingAction() { + let action = pendingAction + pendingAction = nil + action?() + + if let currentPlacement { + preloadAd(for: currentPlacement) + } + } + + private func presentingViewController() -> UIViewController? { + guard + let rootViewController = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first(where: { $0.isKeyWindow })? + .rootViewController + else { + return nil + } + + var topViewController = rootViewController + + while let presentedViewController = topViewController.presentedViewController { + topViewController = presentedViewController + } + + return topViewController + } +} + +extension YandexInterstitialAdManager: InterstitialAdDelegate { + + func interstitialAdDidShow(_ interstitialAd: InterstitialAd) { + } + + func interstitialAdDidDismiss(_ interstitialAd: InterstitialAd) { + completePendingAction() + } + + func interstitialAdDidClick(_ interstitialAd: InterstitialAd) { + } + + func interstitialAd(_ interstitialAd: InterstitialAd, didTrackImpression impressionData: ImpressionData?) { + } + + func interstitialAd(_ interstitialAd: InterstitialAd, didFailToShow error: Error) { + completePendingAction() + } +} diff --git a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift index 2632f1b..fdedd47 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift @@ -23,8 +23,8 @@ struct ContentDetailPlayView: View { @State private var progress: TimeInterval = 0 @State private var timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect() - var body: some View { - VStack(alignment: .leading, spacing: 8) { + var body: some View { + VStack(alignment: .leading, spacing: 8) { ZStack { KFImage(URL(string: audioContent.coverImageUrl)) .cancelOnDisappear(true) @@ -88,9 +88,9 @@ struct ContentDetailPlayView: View { ContentPlayerPlayManager.shared.resetPlayer() contentPlayManager.pauseAudio() } - } else { - if isAlertPreview { - HStack(spacing: 4) { + } else { + if isAlertPreview { + HStack(spacing: 4) { Image("ic_noti_play") .resizable() .frame(width: 24, height: 24) @@ -98,60 +98,21 @@ struct ContentDetailPlayView: View { Text(I18n.ContentDetail.preview) .appFont(size: 16.7, weight: .medium) .foregroundColor(Color.white) - } - .padding(.vertical, 13.3) - .frame(minWidth: 212) - .background(Color.black.opacity(0.4)) - .cornerRadius(46.7) - .onTapGesture { - ContentPlayerPlayManager.shared.resetPlayer() - contentPlayManager.startTimer = startTimer - contentPlayManager.stopTimer = stopTimer - - contentPlayManager.playAudio( - contentId: audioContent.contentId, - title: audioContent.title, - nickname: audioContent.creator.nickname, - coverImage: audioContent.coverImageUrl, - contentUrl: audioContent.contentUrl, - isFree: audioContent.price <= 0, - isPreview: !audioContent.existOrdered && audioContent.price > 0 - ) - isShowPreviewAlert = true - - recentContentViewModel.insertRecentContent( - contentId: Int64(audioContent.contentId), - coverImageUrl: audioContent.coverImageUrl, - title: audioContent.title, - creatorNickname: audioContent.creator.nickname - ) - } - } else { - Image("btn_audio_content_play") - .onTapGesture { - ContentPlayerPlayManager.shared.resetPlayer() - contentPlayManager.startTimer = startTimer - contentPlayManager.stopTimer = stopTimer - - contentPlayManager.playAudio( - contentId: audioContent.contentId, - title: audioContent.title, - nickname: audioContent.creator.nickname, - coverImage: audioContent.coverImageUrl, - contentUrl: audioContent.contentUrl, - isFree: audioContent.price <= 0, - isPreview: !audioContent.existOrdered && audioContent.price > 0 - ) - - recentContentViewModel.insertRecentContent( - contentId: Int64(audioContent.contentId), - coverImageUrl: audioContent.coverImageUrl, - title: audioContent.title, - creatorNickname: audioContent.creator.nickname - ) - } - } - } + } + .padding(.vertical, 13.3) + .frame(minWidth: 212) + .background(Color.black.opacity(0.4)) + .cornerRadius(46.7) + .onTapGesture { + handlePlayTap(showPreviewAlert: true) + } + } else { + Image("btn_audio_content_play") + .onTapGesture { + handlePlayTap(showPreviewAlert: false) + } + } + } if !isAlertPreview { Image("ic_player_next_10") @@ -236,29 +197,79 @@ struct ContentDetailPlayView: View { } } .frame(width: screenSize().width - 40) - } - .onAppear { - if !isPlaying() { - stopTimer() - } - } + } + .onAppear { + Task { + await YandexInterstitialAdManager.shared.preloadAd(for: .contentDetail) + } + + if !isPlaying() { + stopTimer() + } + } .onReceive(timer) { _ in guard let player = contentPlayManager.player, !isEditing else { return } self.progress = player.currentTime } } - private func isPlaying() -> Bool { - return contentPlayManager.contentId == audioContent.contentId && contentPlayManager.isPlaying - } - - private func sliderRange() -> ClosedRange { - if audioContent.contentId == contentPlayManager.contentId { - return 0...contentPlayManager.duration - } else { - return 0...0 - } - } + private func isPlaying() -> Bool { + return contentPlayManager.contentId == audioContent.contentId && contentPlayManager.isPlaying + } + + private func handlePlayTap(showPreviewAlert: Bool) { + let playAction: @MainActor () -> Void = { + ContentPlayerPlayManager.shared.resetPlayer() + contentPlayManager.startTimer = startTimer + contentPlayManager.stopTimer = stopTimer + + contentPlayManager.playAudio( + contentId: audioContent.contentId, + title: audioContent.title, + nickname: audioContent.creator.nickname, + coverImage: audioContent.coverImageUrl, + contentUrl: audioContent.contentUrl, + isFree: audioContent.price <= 0, + isPreview: !audioContent.existOrdered && audioContent.price > 0 + ) + + if showPreviewAlert { + isShowPreviewAlert = true + } + + recentContentViewModel.insertRecentContent( + contentId: Int64(audioContent.contentId), + coverImageUrl: audioContent.coverImageUrl, + title: audioContent.title, + creatorNickname: audioContent.creator.nickname + ) + } + + guard shouldShowInterstitialBeforePlayback(showPreviewAlert: showPreviewAlert) else { + playAction() + return + } + + Task { + await YandexInterstitialAdManager.shared.showAdIfAvailable(for: .contentDetail, then: playAction) + } + } + + private func shouldShowInterstitialBeforePlayback(showPreviewAlert: Bool) -> Bool { + if contentPlayManager.contentId == audioContent.contentId { + return false + } + + return audioContent.price <= 0 || showPreviewAlert + } + + private func sliderRange() -> ClosedRange { + if audioContent.contentId == contentPlayManager.contentId { + return 0...contentPlayManager.duration + } else { + return 0...0 + } + } private func getProgress() -> String { if audioContent.contentId == contentPlayManager.contentId { diff --git a/SodaLive/Sources/Content/Detail/ContentDetailView.swift b/SodaLive/Sources/Content/Detail/ContentDetailView.swift index 0806c91..5f2bd5d 100644 --- a/SodaLive/Sources/Content/Detail/ContentDetailView.swift +++ b/SodaLive/Sources/Content/Detail/ContentDetailView.swift @@ -81,14 +81,19 @@ struct ContentDetailView: View { isShowPreviewAlert: $viewModel.isShowPreviewAlert ) - ContentDetailPreviousNextContentButtonView( - previousContent: audioContent.previousContent, - nextContent: audioContent.nextContent - ) { - viewModel.contentId = $0 + if audioContent.previousContent != nil || audioContent.nextContent != nil { + ContentDetailPreviousNextContentButtonView( + previousContent: audioContent.previousContent, + nextContent: audioContent.nextContent + ) { + viewModel.contentId = $0 + } + .padding(.top, 13.3) + .padding(.horizontal, 13.3) } - .padding(.top, 13.3) - .padding(.horizontal, 13.3) + + YandexInlineBannerView(placement: .contentDetail) + .padding(.top, 13.3) ContentDetailInfoView( isExpandDescription: $viewModel.isExpandDescription, diff --git a/SodaLive/Sources/Debug/Utils/Constants.swift b/SodaLive/Sources/Debug/Utils/Constants.swift index 8d26786..b91207c 100644 --- a/SodaLive/Sources/Debug/Utils/Constants.swift +++ b/SodaLive/Sources/Debug/Utils/Constants.swift @@ -32,3 +32,8 @@ let LINE_CHANNEL_ID = "2008995582" let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169" let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515" + +let YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID = "R-M-19140297-3" +let YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID = "R-M-19140297-4" +let YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID = "R-M-19140297-5" +let YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID = "R-M-19140297-6" diff --git a/SodaLive/Sources/Live/LiveView.swift b/SodaLive/Sources/Live/LiveView.swift index 18eca29..b28b23d 100644 --- a/SodaLive/Sources/Live/LiveView.swift +++ b/SodaLive/Sources/Live/LiveView.swift @@ -98,6 +98,9 @@ struct LiveView: View { SectionLatestFinishedLiveView(items: viewModel.latestFinishedLiveItems) } + YandexInlineBannerView(placement: .liveTab) + .padding(.vertical, -24) + if viewModel.replayLiveItems.count > 0 { LiveReplayListView(contentList: viewModel.replayLiveItems) } diff --git a/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift b/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift index 6c2b028..206c2ea 100644 --- a/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift +++ b/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift @@ -118,21 +118,25 @@ struct LiveDetailView: View { .padding(.top, 16.7) .frame(width: proxy.size.width - 26.7) - if UserDefaults.int(forKey: .userId) == room.manager.id { - Rectangle() - .frame(height: 1) - .foregroundColor(Color.gray90.opacity(0.5)) - .padding(.top, 8) + if UserDefaults.int(forKey: .userId) == room.manager.id { + Rectangle() + .frame(height: 1) + .foregroundColor(Color.gray90.opacity(0.5)) + .padding(.top, 8) .frame(width: proxy.size.width - 26.7) - ParticipantView(room: room) - .frame(width: proxy.size.width - 26.7) - } - - Rectangle() - .frame(height: 1) - .foregroundColor(Color.gray90.opacity(0.5)) - .padding(.top, 13.3) + ParticipantView(room: room) + .frame(width: proxy.size.width - 26.7) + } + + YandexInlineBannerView(placement: .liveDetail, horizontalPadding: 0) + .frame(width: proxy.size.width - 26.7) + .padding(.top, 13.3) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color.gray90.opacity(0.5)) + .padding(.top, 13.3) HStack(spacing: 13.3) { let manager = room.manager diff --git a/SodaLive/Sources/Utils/Constants.swift b/SodaLive/Sources/Utils/Constants.swift index 94f112b..2db193c 100644 --- a/SodaLive/Sources/Utils/Constants.swift +++ b/SodaLive/Sources/Utils/Constants.swift @@ -32,3 +32,8 @@ let LINE_CHANNEL_ID = "2008995539" let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169" let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515" + +let YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID = "R-M-19157621-1" +let YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID = "R-M-19157621-2" +let YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID = "R-M-19157621-3" +let YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID = "R-M-19157621-4" diff --git a/docs/20260428_Yandex광고화면배치구현.md b/docs/20260428_Yandex광고화면배치구현.md new file mode 100644 index 0000000..a70b2c9 --- /dev/null +++ b/docs/20260428_Yandex광고화면배치구현.md @@ -0,0 +1,105 @@ +# 20260428 Yandex 광고 화면 배치 구현 + +## 작업 체크리스트 +- [x] Yandex 광고 SDK/초기화/기존 인프라 확인 +- [x] 광고 삽입 대상 화면과 정확한 위치 확정 +- [x] 공용 Yandex 배너/전면광고 SwiftUI 브리지 구현 +- [x] `LiveView` 최근 종료 라이브와 다시 듣기 사이 배너 삽입 +- [x] `LiveDetailView` 참여자 목록과 크리에이터 프로필 사이 배너 삽입 +- [x] `ContentDetailView` 오픈예정/theme 표시와 다음화/이전화 사이 배너 삽입 +- [x] `ContentDetailPlayView` 무료 재생/미리듣기 시작 전 전면광고 삽입 +- [x] 빌드 및 정적 검증 기록 추가 + +## 작업 기준 + +- 공식 문서: + - `https://ads.yandex.com/helpcenter/ko/dev/ios/adaptive-inline-banner` + - `https://ads.yandex.com/helpcenter/ko/dev/ios/interstitial` +- 기존 SDK 상태: + - `SodaLive/Sources/App/AppDelegate.swift` + - `Podfile` + - `Podfile.lock` +- 수정 대상 예상: + - `SodaLive/Sources/Common/YandexInlineBannerView.swift` + - `SodaLive/Sources/Common/YandexInterstitialAdManager.swift` + - `SodaLive/Sources/Live/LiveView.swift` + - `SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift` + - `SodaLive/Sources/Content/Detail/ContentDetailView.swift` + - `SodaLive/Sources/Content/Detail/ContentDetailPlayView.swift` + - `SodaLive.xcodeproj/project.pbxproj` + +## QA 기준 + +- Live 탭에서 최근 종료한 라이브 섹션 아래, 라이브 다시 듣기 섹션 위에 배너가 표시된다. +- 라이브 상세 바텀시트에서 참여자 영역 아래, 크리에이터 프로필 위에 배너가 표시된다. +- 콘텐츠 상세에서 오픈예정/theme 정보 아래, 다음화/이전화 버튼 위에 배너가 표시된다. +- 무료 콘텐츠 재생과 유료 콘텐츠 미리듣기 시작 시 전면광고 표시 이후 오디오 재생이 이어진다. +- 구매 완료 콘텐츠 등 일반 유료 재생은 전면광고 없이 기존처럼 바로 재생된다. + +## 구현 메모 + +- 저장소에 실제 Yandex 운영 ad unit id는 없으므로 `SodaLive/Sources/Utils/Constants.swift`, `SodaLive/Sources/Debug/Utils/Constants.swift`에 공식 demo unit(`demo-banner-yandex`, `demo-interstitial-yandex`) 상수를 추가했다. +- 운영 unit 치환은 각 Constants 파일의 placement별 상수(`YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID`, `YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID`, `YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID`, `YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID`) 값만 교체하면 되도록 구성한다. +- 전면광고는 `ContentDetailPlayView`의 실제 `contentPlayManager.playAudio(...)` 호출 직전에만 개입하고, 공용 플레이어 매니저 로직은 건드리지 않는다. + +## 검증 기록 + +- 2026-04-28 / 사전 조사 + - 무엇: SDK 초기화 여부, 광고 인프라 존재 여부, 삽입 위치, Yandex 공식 배너/전면광고 API 요구사항을 확인했다. + - 왜: 이미 연결된 SDK를 재설정하지 않고 최소 변경 경로로 광고 삽입을 구현하기 위해서다. + - 어떻게: + - `AppDelegate.swift`, `Podfile`, `Podfile.lock`, 대상 SwiftUI 화면 파일을 확인했다. + - background `explore`/`librarian`로 내부 광고 패턴 부재와 Yandex 공식 API(`AdView`, `InterstitialAdLoader`)를 교차 검증했다. + - 결과: + - SDK 초기화와 plist 준비는 완료돼 있었고, 광고 전용 SwiftUI 브리지는 아직 없었다. + - 구현은 공용 브리지 추가 + 세 화면 삽입 + 재생 트리거 가드 추가로 수렴했다. + +- 2026-04-28 / 구현 및 빌드 검증 + - 무엇: 공용 Yandex 광고 지원 파일 추가, 세 화면 배너 삽입, 콘텐츠 재생 전면광고 가드, 빌드 모드별 unit id 조회 경로를 구현했다. + - 왜: 기존 SDK 초기화 상태를 유지하면서 요청한 위치와 트리거에만 광고를 정확히 추가하기 위해서다. + - 어떻게: + - `SodaLive/Sources/Common/YandexAdSupport.swift`에 `BannerAdView`, `InterstitialAdLoader.loadAd(with:completion:)`, `InterstitialAdDelegate` 기반 공용 로직을 추가했다. + - `LiveView`, `LiveDetailView`, `ContentDetailView`, `ContentDetailPlayView`를 최소 변경으로 수정했다. + - `SodaLive/Sources/Utils/Constants.swift`, `SodaLive/Sources/Debug/Utils/Constants.swift`에 `YANDEX_BANNER_AD_UNIT_ID`, `YANDEX_INTERSTITIAL_AD_UNIT_ID` 상수를 추가했다. + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `plutil -lint "SodaLive/Resources/Info.plist"` + - `plutil -lint "SodaLive/Resources/Debug/SodaLive-dev-Info.plist"` + - 각 빌드 타깃에서 상수 참조와 컴파일 성공 여부를 확인했다. + - 결과: + - `SodaLive-dev` Debug 빌드 성공 + - `SodaLive` Debug 빌드 성공 + - 두 plist 문법 검증 성공 + - 두 타깃 모두 Constants 기반 unit id 참조 상태로 빌드 성공 + - SourceKit `lsp_diagnostics`는 `YandexMobileAds`, `Kingfisher`, `RefreshableScrollView` 외부 모듈을 단독 해석하지 못해 모듈 미해결 오류를 보고했으나, 실제 `xcodebuild` 실컴파일은 통과했다. + +- 2026-04-28 / unit id 저장 위치 Constants 전환 + - 무엇: Yandex 광고 unit id 저장 위치를 `Info.plist`에서 `Constants.swift`/`Debug/Utils/Constants.swift`로 이동했다. + - 왜: 요청대로 unit id를 앱 상수 레이어에서 타깃별로 관리하고, plist에는 저장하지 않기 위해서다. + - 어떻게: + - `SodaLive/Sources/Utils/Constants.swift`에 `YANDEX_BANNER_AD_UNIT_ID`, `YANDEX_INTERSTITIAL_AD_UNIT_ID` 추가 + - `SodaLive/Sources/Debug/Utils/Constants.swift`에 동일 상수 추가 + - `SodaLive/Sources/Common/YandexAdSupport.swift`의 `Bundle.main.object(forInfoDictionaryKey:)` 제거 + - 두 plist에서 `Yandex*AdUnitID*` 키 제거 + - `grep`로 남은 Yandex Info.plist 조회/키 참조가 없는지 확인 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `plutil -lint "SodaLive/Resources/Info.plist"` + - `plutil -lint "SodaLive/Resources/Debug/SodaLive-dev-Info.plist"` + - 결과: + - Yandex unit id의 plist 저장/조회 흔적 제거 완료 + - `SodaLive-dev` Debug 빌드 성공 + - `SodaLive` Debug 빌드 성공 + - 두 plist 문법 검증 성공 + +- 2026-04-28 / placement별 광고 ID 및 Sendable 경고 대응 + - 무엇: 공용 광고 ID를 placement별 상수로 분리하고, interstitial preload를 async/await 기반으로 변경했다. + - 왜: Live 탭/라이브 상세/콘텐츠 상세/콘텐츠 상세 전면광고가 서로 다른 광고 ID를 사용해야 하고, `@Sendable` completion closure의 `self` 캡처 경고도 제거해야 했기 때문이다. + - 어떻게: + - `YandexAdSupport.swift`에 `YandexBannerPlacement`, `YandexInterstitialPlacement`를 추가했다. + - 배너는 placement별 `AdRequest(adUnitID:)`를 사용하도록 변경했다. + - interstitial은 `@MainActor` + `try await interstitialAdLoader.loadAd(with:)` 패턴으로 변경했다. + - `LiveView`, `LiveDetailView`, `ContentDetailView`, `ContentDetailPlayView`에서 각 placement를 명시적으로 전달하도록 수정했다. + - 결과: + - 페이지별 광고 ID를 독립적으로 설정할 수 있는 구조로 전환됨 + - completion closure 기반 `self` 캡처 경고 제거 대상 구조를 async/await로 대체함