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()
|
||||
}
|
||||
}
|
||||
@@ -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<Double> {
|
||||
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<Double> {
|
||||
if audioContent.contentId == contentPlayManager.contentId {
|
||||
return 0...contentPlayManager.duration
|
||||
} else {
|
||||
return 0...0
|
||||
}
|
||||
}
|
||||
|
||||
private func getProgress() -> String {
|
||||
if audioContent.contentId == contentPlayManager.contentId {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user