feat(yandex-ads): 화면별 Yandex 광고 배치를 추가한다
This commit is contained in:
278
SodaLive/Sources/Common/YandexAdSupport.swift
Normal file
278
SodaLive/Sources/Common/YandexAdSupport.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,51 +104,12 @@ struct ContentDetailPlayView: View {
|
|||||||
.background(Color.black.opacity(0.4))
|
.background(Color.black.opacity(0.4))
|
||||||
.cornerRadius(46.7)
|
.cornerRadius(46.7)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
ContentPlayerPlayManager.shared.resetPlayer()
|
handlePlayTap(showPreviewAlert: true)
|
||||||
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 {
|
} else {
|
||||||
Image("btn_audio_content_play")
|
Image("btn_audio_content_play")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
ContentPlayerPlayManager.shared.resetPlayer()
|
handlePlayTap(showPreviewAlert: false)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +199,10 @@ struct ContentDetailPlayView: View {
|
|||||||
.frame(width: screenSize().width - 40)
|
.frame(width: screenSize().width - 40)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
await YandexInterstitialAdManager.shared.preloadAd(for: .contentDetail)
|
||||||
|
}
|
||||||
|
|
||||||
if !isPlaying() {
|
if !isPlaying() {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
}
|
}
|
||||||
@@ -252,6 +217,52 @@ struct ContentDetailPlayView: View {
|
|||||||
return contentPlayManager.contentId == audioContent.contentId && contentPlayManager.isPlaying
|
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<Double> {
|
private func sliderRange() -> ClosedRange<Double> {
|
||||||
if audioContent.contentId == contentPlayManager.contentId {
|
if audioContent.contentId == contentPlayManager.contentId {
|
||||||
return 0...contentPlayManager.duration
|
return 0...contentPlayManager.duration
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ struct ContentDetailView: View {
|
|||||||
isShowPreviewAlert: $viewModel.isShowPreviewAlert
|
isShowPreviewAlert: $viewModel.isShowPreviewAlert
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if audioContent.previousContent != nil || audioContent.nextContent != nil {
|
||||||
ContentDetailPreviousNextContentButtonView(
|
ContentDetailPreviousNextContentButtonView(
|
||||||
previousContent: audioContent.previousContent,
|
previousContent: audioContent.previousContent,
|
||||||
nextContent: audioContent.nextContent
|
nextContent: audioContent.nextContent
|
||||||
@@ -89,6 +90,10 @@ struct ContentDetailView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, 13.3)
|
.padding(.top, 13.3)
|
||||||
.padding(.horizontal, 13.3)
|
.padding(.horizontal, 13.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
YandexInlineBannerView(placement: .contentDetail)
|
||||||
|
.padding(.top, 13.3)
|
||||||
|
|
||||||
ContentDetailInfoView(
|
ContentDetailInfoView(
|
||||||
isExpandDescription: $viewModel.isExpandDescription,
|
isExpandDescription: $viewModel.isExpandDescription,
|
||||||
|
|||||||
@@ -32,3 +32,8 @@ let LINE_CHANNEL_ID = "2008995582"
|
|||||||
|
|
||||||
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
||||||
let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"
|
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"
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ struct LiveView: View {
|
|||||||
SectionLatestFinishedLiveView(items: viewModel.latestFinishedLiveItems)
|
SectionLatestFinishedLiveView(items: viewModel.latestFinishedLiveItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
YandexInlineBannerView(placement: .liveTab)
|
||||||
|
.padding(.vertical, -24)
|
||||||
|
|
||||||
if viewModel.replayLiveItems.count > 0 {
|
if viewModel.replayLiveItems.count > 0 {
|
||||||
LiveReplayListView(contentList: viewModel.replayLiveItems)
|
LiveReplayListView(contentList: viewModel.replayLiveItems)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ struct LiveDetailView: View {
|
|||||||
.frame(width: proxy.size.width - 26.7)
|
.frame(width: proxy.size.width - 26.7)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
YandexInlineBannerView(placement: .liveDetail, horizontalPadding: 0)
|
||||||
|
.frame(width: proxy.size.width - 26.7)
|
||||||
|
.padding(.top, 13.3)
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
.foregroundColor(Color.gray90.opacity(0.5))
|
.foregroundColor(Color.gray90.opacity(0.5))
|
||||||
|
|||||||
@@ -32,3 +32,8 @@ let LINE_CHANNEL_ID = "2008995539"
|
|||||||
|
|
||||||
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
||||||
let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"
|
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"
|
||||||
|
|||||||
105
docs/20260428_Yandex광고화면배치구현.md
Normal file
105
docs/20260428_Yandex광고화면배치구현.md
Normal file
@@ -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로 대체함
|
||||||
Reference in New Issue
Block a user