Files
sodalive-ios/SodaLive/Sources/Common/YandexAdSupport.swift

442 lines
13 KiB
Swift

//
// YandexAdSupport.swift
// SodaLive
//
// Created by OpenCode on 2026/04/28.
//
import SwiftUI
import YandexMobileAds
enum YandexBannerPlacement {
case liveTab
case liveDetail
case contentDetail
case creatorCommunityAll
case seriesMainHome
case seriesMainDayOfWeek
case seriesMainByGenre
case pushNotificationList
case notificationReceiveSettings
case chatCharacterList
case chatOriginalTabTop
case chatTalkTabTop
}
enum YandexInterstitialPlacement {
case contentDetail
}
enum YandexRewardedPlacement {
case chatRoomQuota
}
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
case .creatorCommunityAll:
YANDEX_CREATOR_COMMUNITY_ALL_BANNER_AD_UNIT_ID
case .seriesMainHome:
YANDEX_SERIES_MAIN_HOME_BANNER_AD_UNIT_ID
case .seriesMainDayOfWeek:
YANDEX_SERIES_MAIN_DAY_OF_WEEK_BANNER_AD_UNIT_ID
case .seriesMainByGenre:
YANDEX_SERIES_MAIN_BY_GENRE_BANNER_AD_UNIT_ID
case .pushNotificationList:
YANDEX_PUSH_NOTIFICATION_LIST_BANNER_AD_UNIT_ID
case .notificationReceiveSettings:
YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID
case .chatCharacterList:
YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID
case .chatOriginalTabTop:
YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID
case .chatTalkTabTop:
YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID
}
}
static func interstitial(for placement: YandexInterstitialPlacement) -> String {
switch placement {
case .contentDetail:
YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID
}
}
static func rewarded(for placement: YandexRewardedPlacement) -> String {
switch placement {
case .chatRoomQuota:
YANDEX_CHAT_ROOM_QUOTA_REWARDED_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 = true
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()
}
}
@MainActor
final class YandexRewardedAdManager: NSObject {
static let shared = YandexRewardedAdManager()
private var rewardedAd: RewardedAd?
private var rewardedAdLoader: RewardedAdLoader?
private var currentPlacement: YandexRewardedPlacement?
private var pendingRewardAction: (@MainActor () -> Void)?
private var rewardedPlacement: YandexRewardedPlacement?
private var isLoading = false
func preloadAd(for placement: YandexRewardedPlacement) {
guard !isLoading else {
return
}
if currentPlacement == placement, rewardedAd != nil {
return
}
let loader = RewardedAdLoader()
rewardedAdLoader = loader
rewardedAd = nil
currentPlacement = placement
isLoading = true
Task {
do {
let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.rewarded(for: placement)))
guard currentPlacement == placement else {
isLoading = false
return
}
loadedAd.delegate = self
rewardedAd = loadedAd
} catch {
if currentPlacement == placement {
rewardedAd = nil
}
}
if currentPlacement == placement {
isLoading = false
}
}
}
func showAdIfAvailable(for placement: YandexRewardedPlacement, onReward: @escaping @MainActor () -> Void) -> Bool {
guard let presenter = presentingViewController(), let rewardedAd, currentPlacement == placement else {
preloadAd(for: placement)
return false
}
pendingRewardAction = onReward
rewardedPlacement = placement
rewardedAd.show(from: presenter)
return true
}
private func completeRewardIfNeeded() {
let action = pendingRewardAction
pendingRewardAction = nil
action?()
}
private func resetAndPreload() {
pendingRewardAction = nil
rewardedAd = nil
if let rewardedPlacement {
self.rewardedPlacement = nil
preloadAd(for: rewardedPlacement)
}
}
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 YandexRewardedAdManager: RewardedAdDelegate {
func rewardedAd(_ rewardedAd: RewardedAd, didReward reward: Reward) {
DEBUG_LOG("리워드 광고 보상 받기 성공")
completeRewardIfNeeded()
}
func rewardedAd(_ rewardedAd: RewardedAd, didFailToShow error: Error) {
DEBUG_LOG("리워드 광고 에러")
resetAndPreload()
}
func rewardedAdDidShow(_ rewardedAd: RewardedAd) {
}
func rewardedAdDidDismiss(_ rewardedAd: RewardedAd) {
DEBUG_LOG("리워드 광고 닫기")
resetAndPreload()
}
func rewardedAdDidClick(_ rewardedAd: RewardedAd) {
}
func rewardedAd(_ rewardedAd: RewardedAd, didTrackImpression impressionData: ImpressionData?) {
}
}