feat(ads): 라이브와 콘텐츠 상세 광고 지면을 추가한다

This commit is contained in:
2026-04-24 18:29:52 +09:00
parent 4a4cdadef1
commit 31306583d0
8 changed files with 500 additions and 9 deletions

View File

@@ -28,6 +28,18 @@ import coil.transform.RoundedCornersTransformation
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.gson.Gson
import com.orhanobut.logger.Logger
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdError
import com.yandex.mobile.ads.common.AdRequest
import com.yandex.mobile.ads.common.AdRequestConfiguration
import com.yandex.mobile.ads.common.AdRequestError
import com.yandex.mobile.ads.common.ImpressionData
import com.yandex.mobile.ads.interstitial.InterstitialAd
import com.yandex.mobile.ads.interstitial.InterstitialAdEventListener
import com.yandex.mobile.ads.interstitial.InterstitialAdLoadListener
import com.yandex.mobile.ads.interstitial.InterstitialAdLoader
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.PurchaseOption
@@ -60,6 +72,7 @@ import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
import kr.co.vividnext.sodalive.report.ReportType
import org.koin.android.ext.android.inject
import kotlin.math.ceil
import kotlin.math.roundToInt
@UnstableApi
class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBinding>(
@@ -88,6 +101,42 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
private lateinit var orderType: OrderType
private lateinit var imm: InputMethodManager
private var audioContentPlayInterstitialAdLoader: InterstitialAdLoader? = null
private var audioContentPlayInterstitialAd: InterstitialAd? = null
private var pendingAudioContentPlayAction: (() -> Unit)? = null
private var hasConsumedAudioContentPlayInterstitialAttempt = false
private var isAudioContentPlaying = false
private var isAudioContentInterstitialEligible = false
private var audioContentStartPlaybackAction: (() -> Unit)? = null
private val audioContentPlayInterstitialAdLoadListener = object : InterstitialAdLoadListener {
override fun onAdLoaded(interstitialAd: InterstitialAd) {
clearAudioContentPlayInterstitialAd()
audioContentPlayInterstitialAd = interstitialAd
}
override fun onAdFailedToLoad(error: AdRequestError) {
Logger.e("Audio content interstitial failed to load: ${error.description}")
}
}
private val audioContentPlayInterstitialAdEventListener = object : InterstitialAdEventListener {
override fun onAdShown() = Unit
override fun onAdFailedToShow(adError: AdError) {
Logger.e("Audio content interstitial failed to show: ${adError.description}")
continuePendingAudioContentPlayAction()
}
override fun onAdDismissed() {
continuePendingAudioContentPlayAction()
}
override fun onAdClicked() = Unit
override fun onAdImpression(impressionData: ImpressionData?) = Unit
}
@SuppressLint("SetTextI18n")
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
@@ -105,6 +154,9 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
binding.rlPreviewAlert.visibility = View.GONE
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
hasConsumedAudioContentPlayInterstitialAttempt = false
releaseAudioContentPlayInterstitial()
setupAudioContentPlayInterstitial()
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
@@ -315,9 +367,125 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
dialog.show(screenWidth - 26.7f.dpToPx().toInt())
}
setupAudioContentDetailInlineBanner()
setupAudioContentPlayInterstitial()
setupBuyerList()
}
private fun setupAudioContentDetailInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
this@AudioContentDetailActivity,
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupAudioContentPlayInterstitial() {
val adUnitId = BuildConfig.YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID
if (adUnitId.isBlank()) {
Logger.e("Audio content interstitial blocked: ad unit id is blank.")
return
}
audioContentPlayInterstitialAdLoader = InterstitialAdLoader(this).apply {
setAdLoadListener(audioContentPlayInterstitialAdLoadListener)
}
audioContentPlayInterstitialAdLoader?.loadAd(
AdRequestConfiguration.Builder(adUnitId).build()
)
}
private fun playAudioContentWithInterstitialIfAvailable(playAction: () -> Unit) {
if (hasConsumedAudioContentPlayInterstitialAttempt || isFinishing || isDestroyed) {
playAction()
return
}
val interstitialAd = audioContentPlayInterstitialAd ?: run {
playAction()
return
}
hasConsumedAudioContentPlayInterstitialAttempt = true
pendingAudioContentPlayAction = playAction
interstitialAd.setAdEventListener(audioContentPlayInterstitialAdEventListener)
runCatching {
interstitialAd.show(this)
}.onFailure {
Logger.e("Audio content interstitial failed to show: ${it.message}")
continuePendingAudioContentPlayAction()
}
}
private fun continuePendingAudioContentPlayAction() {
val playAction = pendingAudioContentPlayAction
pendingAudioContentPlayAction = null
clearAudioContentPlayInterstitialAd()
if (isFinishing || isDestroyed) {
return
}
playAction?.invoke()
}
private fun clearAudioContentPlayInterstitialAd() {
audioContentPlayInterstitialAd?.setAdEventListener(null)
audioContentPlayInterstitialAd = null
}
private fun releaseAudioContentPlayInterstitial() {
audioContentPlayInterstitialAdLoader?.setAdLoadListener(null)
audioContentPlayInterstitialAdLoader = null
pendingAudioContentPlayAction = null
clearAudioContentPlayInterstitialAd()
}
private fun pauseAudioContentPlayback() {
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.PAUSE.name
}
)
}
private fun updateAudioContentPlayOrPauseControls() {
val startPlaybackAction = audioContentStartPlaybackAction ?: return
if (isAudioContentPlaying) {
binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_pause)
binding.ivPlayOrPause.setOnClickListener { pauseAudioContentPlayback() }
binding.llPreview.setOnClickListener(null)
return
}
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
val startPlaybackClickListener = View.OnClickListener {
if (isAudioContentInterstitialEligible) {
playAudioContentWithInterstitialIfAvailable(startPlaybackAction)
} else {
startPlaybackAction()
}
}
binding.ivPlayOrPause.setOnClickListener(startPlaybackClickListener)
binding.llPreview.setOnClickListener(startPlaybackClickListener)
}
private fun setupBuyerList() {
val recyclerView = binding.rvBuyer
contentBuyerAdapter = AudioContentBuyerAdapter()
@@ -775,10 +943,13 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
R.string.screen_audio_content_detail_total_duration_format,
response.duration
)
audioContentStartPlaybackAction = null
isAudioContentInterstitialEligible = false
isAlertPreview = response.creator.creatorId != SharedPreferenceManager.userId &&
!response.existOrdered &&
response.price > 0
isAudioContentInterstitialEligible = response.price <= 0 || isAlertPreview
if (
response.creator.creatorId != SharedPreferenceManager.userId && !response.existOrdered &&
@@ -798,7 +969,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
binding.ivPlayOrPause.visibility = View.VISIBLE
}
val playClickAction = View.OnClickListener {
val playAudioContentAction: () -> Unit = {
startService(
Intent(
applicationContext,
@@ -842,9 +1013,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
)
}
audioContentStartPlaybackAction = playAudioContentAction
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
binding.ivPlayOrPause.setOnClickListener(playClickAction)
binding.llPreview.setOnClickListener(playClickAction)
updateAudioContentPlayOrPauseControls()
if (!isAlertPreview) {
binding.ivSeekForward10.visibility = View.VISIBLE
@@ -873,6 +1045,8 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
}
}
} else if (response.releaseDate == null) {
audioContentStartPlaybackAction = null
isAudioContentInterstitialEligible = false
binding.llPreviewNo.visibility = View.VISIBLE
}
@@ -1166,7 +1340,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} else {
contentOrder(audioContent, orderType)
}
},
}
).show(screenWidth)
}
@@ -1193,6 +1367,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
}, 100)
}
override fun onDestroy() {
binding.yandexInlineBannerView.destroy()
releaseAudioContentPlayInterstitial()
super.onDestroy()
}
inner class AudioContentReceiver : BroadcastReceiver() {
@SuppressLint("SetTextI18n")
override fun onReceive(context: Context?, intent: Intent?) {
@@ -1222,12 +1402,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
viewModel.isLoading.value = isLoading == true
if (this@AudioContentDetailActivity.audioContentId == contentId) {
isAudioContentPlaying = isPlaying == true
runOnUiThread {
if (changeUi != null && changeUi) {
if (isPlaying != null && isPlaying) {
binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_pause)
} else {
if (isAlertPreview) {
binding.ivPlayOrPause.visibility = View.GONE
@@ -1235,9 +1415,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} else {
binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
}
}
updateAudioContentPlayOrPauseControls()
}
}

View File

@@ -22,9 +22,12 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
@@ -131,6 +134,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
@@ -158,9 +162,31 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
setupRecommendChannel()
setupLatestFinishedLiveChannel()
setupLiveReplay()
setupLiveTabInlineBanner()
setupLiveReservation()
}
private fun setupLiveTabInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun renderMakeLiveByRole(role: String) {
if (role == MemberRole.CREATOR.name) {
binding.llMakeLive.visibility = View.VISIBLE

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.live.room.detail
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -20,6 +19,9 @@ import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
@@ -35,6 +37,7 @@ import org.koin.android.ext.android.inject
import java.util.Locale
import java.util.TimeZone
import androidx.core.net.toUri
import kotlin.math.roundToInt
class LiveRoomDetailFragment(
private val roomId: Long,
@@ -79,11 +82,39 @@ class LiveRoomDetailFragment(
behavior.state = BottomSheetBehavior.STATE_EXPANDED
setupAdapter()
setupLiveRoomDetailInlineBanner()
bindData()
binding.ivClose.setOnClickListener { dismiss() }
viewModel.getDetail(roomId) { dismiss() }
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupLiveRoomDetailInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val screenWidth = resources.displayMetrics.widthPixels
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupAdapter() {
val recyclerView = binding.rvParticipate
adapter = LiveRoomDetailAdapter {}
@@ -384,7 +415,7 @@ class LiveRoomDetailFragment(
viewModel.shareRoomLink(
response.roomId,
response.isPrivateRoom,
response.password,
response.password
) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"

View File

@@ -396,6 +396,14 @@
android:visibility="gone" />
</LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="90dp"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="13.3dp" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -143,11 +143,19 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="48dp"
android:layout_marginBottom="24dp"
android:clipToPadding="false"
android:paddingHorizontal="24dp"
android:visibility="gone" />
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:maxHeight="90dp" />
<LinearLayout
android:id="@+id/ll_replay_live"
android:layout_width="match_parent"

View File

@@ -187,6 +187,14 @@
app:drawableStartCompat="@drawable/ic_live_detail_bottom" />
</LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="90dp"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="13.3dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"