feat(ads): 라이브와 콘텐츠 상세 광고 지면을 추가한다
This commit is contained in:
@@ -76,6 +76,10 @@ android {
|
|||||||
// release용 ad unit id는 배포 전 실제 값으로 교체한다.
|
// release용 ad unit id는 배포 전 실제 값으로 교체한다.
|
||||||
buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140295-1"'
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140295-1"'
|
||||||
buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140295-2"'
|
buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140295-2"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140295-3"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140295-4"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140295-6"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140295-5"'
|
||||||
buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"'
|
buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"'
|
||||||
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
|
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
|
||||||
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
|
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
|
||||||
@@ -108,6 +112,10 @@ android {
|
|||||||
|
|
||||||
buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140297-1"'
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID', '"R-M-19140297-1"'
|
||||||
buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140297-2"'
|
buildConfigField 'String', 'YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID', '"R-M-19140297-2"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140297-3"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140297-4"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140297-6"'
|
||||||
|
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140297-5"'
|
||||||
buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"'
|
buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"'
|
||||||
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
|
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
|
||||||
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
|
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ import coil.transform.RoundedCornersTransformation
|
|||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.google.gson.Gson
|
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.R
|
||||||
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
||||||
import kr.co.vividnext.sodalive.audio_content.PurchaseOption
|
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 kr.co.vividnext.sodalive.report.ReportType
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBinding>(
|
class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBinding>(
|
||||||
@@ -88,6 +101,42 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
private lateinit var orderType: OrderType
|
private lateinit var orderType: OrderType
|
||||||
private lateinit var imm: InputMethodManager
|
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")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
@@ -105,6 +154,9 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
binding.rlPreviewAlert.visibility = View.GONE
|
binding.rlPreviewAlert.visibility = View.GONE
|
||||||
|
|
||||||
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
|
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
|
||||||
|
hasConsumedAudioContentPlayInterstitialAttempt = false
|
||||||
|
releaseAudioContentPlayInterstitial()
|
||||||
|
setupAudioContentPlayInterstitial()
|
||||||
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
|
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,9 +367,125 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
dialog.show(screenWidth - 26.7f.dpToPx().toInt())
|
dialog.show(screenWidth - 26.7f.dpToPx().toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupAudioContentDetailInlineBanner()
|
||||||
|
setupAudioContentPlayInterstitial()
|
||||||
setupBuyerList()
|
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() {
|
private fun setupBuyerList() {
|
||||||
val recyclerView = binding.rvBuyer
|
val recyclerView = binding.rvBuyer
|
||||||
contentBuyerAdapter = AudioContentBuyerAdapter()
|
contentBuyerAdapter = AudioContentBuyerAdapter()
|
||||||
@@ -775,10 +943,13 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
R.string.screen_audio_content_detail_total_duration_format,
|
R.string.screen_audio_content_detail_total_duration_format,
|
||||||
response.duration
|
response.duration
|
||||||
)
|
)
|
||||||
|
audioContentStartPlaybackAction = null
|
||||||
|
isAudioContentInterstitialEligible = false
|
||||||
|
|
||||||
isAlertPreview = response.creator.creatorId != SharedPreferenceManager.userId &&
|
isAlertPreview = response.creator.creatorId != SharedPreferenceManager.userId &&
|
||||||
!response.existOrdered &&
|
!response.existOrdered &&
|
||||||
response.price > 0
|
response.price > 0
|
||||||
|
isAudioContentInterstitialEligible = response.price <= 0 || isAlertPreview
|
||||||
|
|
||||||
if (
|
if (
|
||||||
response.creator.creatorId != SharedPreferenceManager.userId && !response.existOrdered &&
|
response.creator.creatorId != SharedPreferenceManager.userId && !response.existOrdered &&
|
||||||
@@ -798,7 +969,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
binding.ivPlayOrPause.visibility = View.VISIBLE
|
binding.ivPlayOrPause.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
val playClickAction = View.OnClickListener {
|
val playAudioContentAction: () -> Unit = {
|
||||||
startService(
|
startService(
|
||||||
Intent(
|
Intent(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
@@ -842,9 +1013,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audioContentStartPlaybackAction = playAudioContentAction
|
||||||
|
|
||||||
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
|
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
|
||||||
binding.ivPlayOrPause.setOnClickListener(playClickAction)
|
updateAudioContentPlayOrPauseControls()
|
||||||
binding.llPreview.setOnClickListener(playClickAction)
|
|
||||||
|
|
||||||
if (!isAlertPreview) {
|
if (!isAlertPreview) {
|
||||||
binding.ivSeekForward10.visibility = View.VISIBLE
|
binding.ivSeekForward10.visibility = View.VISIBLE
|
||||||
@@ -873,6 +1045,8 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (response.releaseDate == null) {
|
} else if (response.releaseDate == null) {
|
||||||
|
audioContentStartPlaybackAction = null
|
||||||
|
isAudioContentInterstitialEligible = false
|
||||||
binding.llPreviewNo.visibility = View.VISIBLE
|
binding.llPreviewNo.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,7 +1340,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
} else {
|
} else {
|
||||||
contentOrder(audioContent, orderType)
|
contentOrder(audioContent, orderType)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
).show(screenWidth)
|
).show(screenWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,6 +1367,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
binding.yandexInlineBannerView.destroy()
|
||||||
|
releaseAudioContentPlayInterstitial()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
inner class AudioContentReceiver : BroadcastReceiver() {
|
inner class AudioContentReceiver : BroadcastReceiver() {
|
||||||
@SuppressLint("SetTextI18n")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
@@ -1222,12 +1402,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
viewModel.isLoading.value = isLoading == true
|
viewModel.isLoading.value = isLoading == true
|
||||||
|
|
||||||
if (this@AudioContentDetailActivity.audioContentId == contentId) {
|
if (this@AudioContentDetailActivity.audioContentId == contentId) {
|
||||||
|
isAudioContentPlaying = isPlaying == true
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (changeUi != null && changeUi) {
|
if (changeUi != null && changeUi) {
|
||||||
if (isPlaying != null && isPlaying) {
|
if (isPlaying != null && isPlaying) {
|
||||||
binding.ivPlayOrPause.visibility = View.VISIBLE
|
binding.ivPlayOrPause.visibility = View.VISIBLE
|
||||||
binding.llPreview.visibility = View.GONE
|
binding.llPreview.visibility = View.GONE
|
||||||
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_pause)
|
|
||||||
} else {
|
} else {
|
||||||
if (isAlertPreview) {
|
if (isAlertPreview) {
|
||||||
binding.ivPlayOrPause.visibility = View.GONE
|
binding.ivPlayOrPause.visibility = View.GONE
|
||||||
@@ -1235,9 +1415,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
|||||||
} else {
|
} else {
|
||||||
binding.ivPlayOrPause.visibility = View.VISIBLE
|
binding.ivPlayOrPause.visibility = View.VISIBLE
|
||||||
binding.llPreview.visibility = View.GONE
|
binding.llPreview.visibility = View.GONE
|
||||||
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAudioContentPlayOrPauseControls()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.gson.Gson
|
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.bannerview.BaseBannerAdapter
|
||||||
import com.zhpan.indicator.enums.IndicatorSlideMode
|
import com.zhpan.indicator.enums.IndicatorSlideMode
|
||||||
import com.zhpan.indicator.enums.IndicatorStyle
|
import com.zhpan.indicator.enums.IndicatorStyle
|
||||||
|
import kr.co.vividnext.sodalive.BuildConfig
|
||||||
import kr.co.vividnext.sodalive.R
|
import kr.co.vividnext.sodalive.R
|
||||||
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
||||||
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
|
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
|
||||||
@@ -131,6 +134,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
binding.yandexInlineBannerView.destroy()
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +162,31 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
|
|||||||
setupRecommendChannel()
|
setupRecommendChannel()
|
||||||
setupLatestFinishedLiveChannel()
|
setupLatestFinishedLiveChannel()
|
||||||
setupLiveReplay()
|
setupLiveReplay()
|
||||||
|
setupLiveTabInlineBanner()
|
||||||
setupLiveReservation()
|
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) {
|
private fun renderMakeLiveByRole(role: String) {
|
||||||
if (role == MemberRole.CREATOR.name) {
|
if (role == MemberRole.CREATOR.name) {
|
||||||
binding.llMakeLive.visibility = View.VISIBLE
|
binding.llMakeLive.visibility = View.VISIBLE
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.live.room.detail
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -20,6 +19,9 @@ import coil.transform.CircleCropTransformation
|
|||||||
import coil.transform.RoundedCornersTransformation
|
import coil.transform.RoundedCornersTransformation
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
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.R
|
||||||
import kr.co.vividnext.sodalive.common.Constants
|
import kr.co.vividnext.sodalive.common.Constants
|
||||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
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.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class LiveRoomDetailFragment(
|
class LiveRoomDetailFragment(
|
||||||
private val roomId: Long,
|
private val roomId: Long,
|
||||||
@@ -79,11 +82,39 @@ class LiveRoomDetailFragment(
|
|||||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
|
||||||
setupAdapter()
|
setupAdapter()
|
||||||
|
setupLiveRoomDetailInlineBanner()
|
||||||
bindData()
|
bindData()
|
||||||
binding.ivClose.setOnClickListener { dismiss() }
|
binding.ivClose.setOnClickListener { dismiss() }
|
||||||
viewModel.getDetail(roomId) { 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() {
|
private fun setupAdapter() {
|
||||||
val recyclerView = binding.rvParticipate
|
val recyclerView = binding.rvParticipate
|
||||||
adapter = LiveRoomDetailAdapter {}
|
adapter = LiveRoomDetailAdapter {}
|
||||||
@@ -384,7 +415,7 @@ class LiveRoomDetailFragment(
|
|||||||
viewModel.shareRoomLink(
|
viewModel.shareRoomLink(
|
||||||
response.roomId,
|
response.roomId,
|
||||||
response.isPrivateRoom,
|
response.isPrivateRoom,
|
||||||
response.password,
|
response.password
|
||||||
) {
|
) {
|
||||||
val intent = Intent(Intent.ACTION_SEND)
|
val intent = Intent(Intent.ACTION_SEND)
|
||||||
intent.type = "text/plain"
|
intent.type = "text/plain"
|
||||||
|
|||||||
@@ -396,6 +396,14 @@
|
|||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
</LinearLayout>
|
</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
|
<RelativeLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -143,11 +143,19 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
android:layout_marginBottom="48dp"
|
android:layout_marginBottom="24dp"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:paddingHorizontal="24dp"
|
android:paddingHorizontal="24dp"
|
||||||
android:visibility="gone" />
|
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
|
<LinearLayout
|
||||||
android:id="@+id/ll_replay_live"
|
android:id="@+id/ll_replay_live"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -187,6 +187,14 @@
|
|||||||
app:drawableStartCompat="@drawable/ic_live_detail_bottom" />
|
app:drawableStartCompat="@drawable/ic_live_detail_bottom" />
|
||||||
</LinearLayout>
|
</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
|
<View
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1dp"
|
android:layout_height="1dp"
|
||||||
|
|||||||
221
docs/20260424_Yandex광고추가구현계획.md
Normal file
221
docs/20260424_Yandex광고추가구현계획.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# 20260424 Yandex 광고 추가 구현 계획
|
||||||
|
|
||||||
|
## 작업 체크리스트
|
||||||
|
- [x] 대상 화면 3곳의 광고 삽입 위치와 기존 Yandex 광고 패턴을 조사한다.
|
||||||
|
QA: `LiveFragment`, `LiveRoomDetailFragment`, `AudioContentDetailActivity`, `MyPageFragment`, `LiveRoomActivity`, 공식 Yandex 문서를 근거로 각 광고 형식과 삽입 위치를 설명할 수 있어야 한다.
|
||||||
|
- [x] AD_UNIT_ID 운영 방식을 기존 Yandex 광고와 같은 구조로 정하고, 지면별 분리 여부를 판단한다.
|
||||||
|
QA: 기존 `BuildConfig` 주입 패턴을 유지하면서 어떤 지면은 반드시 분리하고 어떤 경우에만 공용이 가능한지 문서에 남아 있어야 한다.
|
||||||
|
- [x] `app/build.gradle`에 신규 광고 지면용 ad unit id를 buildType별로 추가한다.
|
||||||
|
QA: `debug`/`release` 모두에서 라이브 탭 배너, 라이브 상세 배너, 콘텐츠 상세 전면 광고, 콘텐츠 상세 배너용 `BuildConfig` 값이 생성되어야 한다.
|
||||||
|
- [x] 라이브 탭의 최근 종료한 라이브와 라이브 다시 듣기 사이에 Yandex adaptive inline banner를 추가한다.
|
||||||
|
QA: `fragment_live.xml`의 `rv_latest_finished_live_channel` 다음, `ll_replay_live` 이전에 배너 컨테이너가 추가되고, 배너 최대 높이는 90dp를 넘지 않아야 한다.
|
||||||
|
- [x] 라이브 상세 bottom sheet의 참여자 목록과 크리에이터 프로필 사이에 Yandex adaptive inline banner를 추가한다.
|
||||||
|
QA: `fragment_live_room_detail.xml`의 `ll_participate_wrapper` 다음, 크리에이터 프로필 `RelativeLayout` 이전에 배너가 배치되고, bottom sheet 해제 시 배너 리소스가 정리되어야 한다.
|
||||||
|
- [x] 콘텐츠 상세에서 무료 콘텐츠 재생 또는 미리듣기 시작 시 Yandex interstitial 광고를 추가한다.
|
||||||
|
QA: `AudioContentDetailActivity.setupPlayArea()`의 실제 재생 시작 클릭 경로에서만 광고를 1회 시도하고, 광고 실패 여부와 무관하게 기존 재생 흐름이 유지되어야 한다.
|
||||||
|
- [x] 콘텐츠 상세의 오픈예정/theme 표시 영역과 이전화/다음화 영역 사이에 Yandex adaptive inline banner를 추가한다.
|
||||||
|
QA: `activity_audio_content_detail.xml`의 `ll_previous_next_content`와 theme/open 예정 `RelativeLayout` 사이에 배너가 추가되고, 최대 높이는 90dp를 넘지 않아야 한다.
|
||||||
|
- [x] 각 화면 생명주기에 맞는 광고 로드/정리 코드를 반영한다.
|
||||||
|
QA: 배너는 화면 종료 시 `destroy()`가 호출되고, 전면 광고는 listener와 ad 참조가 화면 종료 시 정리되어야 한다.
|
||||||
|
- [x] 검증 결과를 문서 하단에 누적 기록한다.
|
||||||
|
QA: 최소 빌드, 테스트, 수동 확인 계획과 실제 실행 결과가 문서 하단에 남아 있어야 한다.
|
||||||
|
|
||||||
|
## 범위 메모
|
||||||
|
- 이번 요청 범위는 아래 4개 광고 지면 추가로 한정한다.
|
||||||
|
- 라이브 탭 배너 1개
|
||||||
|
- 라이브 상세 배너 1개
|
||||||
|
- 콘텐츠 상세 전면 광고 1개
|
||||||
|
- 콘텐츠 상세 배너 1개
|
||||||
|
- AD_UNIT_ID는 기존 Yandex 광고와 동일하게 `app/build.gradle`의 `debug`/`release` `buildConfigField`로 관리한다.
|
||||||
|
- 배너 광고는 모두 Yandex adaptive inline banner를 기준으로 구현한다.
|
||||||
|
- 사용자가 명시한 제약에 따라 모든 배너의 최대 높이는 90dp를 상한으로 둔다.
|
||||||
|
- 전면 광고는 `AudioContentDetailActivity`에서 무료 콘텐츠 재생 또는 미리듣기 시작 시점에만 노출을 시도한다.
|
||||||
|
- 앱 전역 Yandex SDK 의존성과 기본 초기화는 이미 `app/build.gradle`, `SodaLiveApp.kt`에 존재하므로 이번 작업에서 신규 SDK 도입이나 앱 초기화 구조 변경은 제외한다.
|
||||||
|
- 기존 구현 패턴은 배너는 `MyPageFragment`, 전면 광고는 `LiveRoomActivity`를 우선 따른다.
|
||||||
|
- 같은 포맷이라도 페이지 목적과 노출 맥락이 다르면 AD_UNIT_ID를 분리하는 방향을 기본값으로 둔다.
|
||||||
|
- 실제 ad unit id 값은 아직 전달되지 않아 신규 4개 지면은 `BuildConfig`에 교체용 placeholder 문자열로 추가하고, 코드 구조와 주입 경로를 먼저 확정한다.
|
||||||
|
|
||||||
|
## 조사 근거
|
||||||
|
- 기존 배너 구현
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt`
|
||||||
|
- `app/src/main/res/layout/fragment_my.xml`
|
||||||
|
- 기존 전면 광고 구현
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`
|
||||||
|
- 앱 초기화
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/app/SodaLiveApp.kt`
|
||||||
|
- 대상 화면
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
|
||||||
|
- `app/src/main/res/layout/fragment_live.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`
|
||||||
|
- `app/src/main/res/layout/fragment_live_room_detail.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
|
||||||
|
- `app/src/main/res/layout/activity_audio_content_detail.xml`
|
||||||
|
- 공식 문서
|
||||||
|
- `https://ads.yandex.com/helpcenter/ko/dev/android/adaptive-inline-banner`
|
||||||
|
- `https://ads.yandex.com/helpcenter/ko/dev/android/interstitial`
|
||||||
|
|
||||||
|
## 구현 계획
|
||||||
|
|
||||||
|
### 1. ad unit id 주입 지점 확정
|
||||||
|
- 수정 대상: `app/build.gradle`
|
||||||
|
- 계획:
|
||||||
|
- `debug`/`release` 각각에 신규 광고 지면용 `buildConfigField`를 추가한다.
|
||||||
|
- 계획 후보 키
|
||||||
|
- `YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID`
|
||||||
|
- `YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID`
|
||||||
|
- `YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID`
|
||||||
|
- `YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID`
|
||||||
|
- 이유:
|
||||||
|
- 기존 `YANDEX_INLINE_BANNER_MYPAGE_AD_UNIT_ID`, `YANDEX_INTERSTITIAL_LIVE_ROOM_AD_UNIT_ID`와 동일한 관리 방식을 유지해야 하기 때문이다.
|
||||||
|
|
||||||
|
### 1-1. AD_UNIT_ID 분리 전략
|
||||||
|
- 권장안:
|
||||||
|
- 이번 작업의 4개 지면은 모두 **서로 다른 AD_UNIT_ID**를 사용한다.
|
||||||
|
- 근거:
|
||||||
|
- 라이브 탭 배너, 라이브 상세 배너, 콘텐츠 상세 배너는 모두 같은 banner 포맷이지만 화면 맥락과 가시성, 스크롤 위치, 성과 측정 대상이 다르다.
|
||||||
|
- 콘텐츠 상세 전면 광고는 포맷 자체가 interstitial이라 배너와는 반드시 분리해야 한다.
|
||||||
|
- 지면별 AD_UNIT_ID를 분리하면 추후 리포트, fill rate, CTR, 운영 정책 조정, 특정 지면만 교체하는 작업이 쉬워진다.
|
||||||
|
- 공용 ID가 가능한 경우:
|
||||||
|
- 완전히 동일한 포맷이고,
|
||||||
|
- 동일한 UX 목적을 가지며,
|
||||||
|
- 운영/리포트도 합산으로 봐도 되는 경우에만 공용 사용을 검토할 수 있다.
|
||||||
|
- 이번 요청에서의 판단:
|
||||||
|
- 라이브 탭 배너 ↔ 라이브 상세 배너 ↔ 콘텐츠 상세 배너는 위치와 사용자 의도가 모두 달라 공용으로 묶지 않는 것이 낫다.
|
||||||
|
- 따라서 이번 계획에서는 **페이지별·지면별 분리**를 기준으로 문서와 코드 반영을 진행한다.
|
||||||
|
|
||||||
|
### 2. 라이브 탭 배너 추가
|
||||||
|
- 수정 대상:
|
||||||
|
- `app/src/main/res/layout/fragment_live.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
|
||||||
|
- 위치:
|
||||||
|
- `fragment_live.xml`의 `rv_latest_finished_live_channel` 다음, `ll_replay_live` 이전
|
||||||
|
- 계획:
|
||||||
|
- 스크롤 섹션 사이에 `BannerAdView` 또는 배너 전용 컨테이너를 추가한다.
|
||||||
|
- `LiveFragment`에 기존 `MyPageFragment.setupBottomInlineBanner()`와 같은 크기 계산/로드 로직을 화면 구조에 맞게 추가한다.
|
||||||
|
- `maxAdHeightDp`는 90으로 제한한다.
|
||||||
|
- `onDestroyView()`에서 배너 `destroy()`를 호출한다.
|
||||||
|
|
||||||
|
### 3. 라이브 상세 배너 추가
|
||||||
|
- 수정 대상:
|
||||||
|
- `app/src/main/res/layout/fragment_live_room_detail.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`
|
||||||
|
- 위치:
|
||||||
|
- `ll_participate_wrapper` 아래, 크리에이터 프로필 `RelativeLayout` 위
|
||||||
|
- 계획:
|
||||||
|
- bottom sheet의 세로 흐름을 유지하면서 배너 컨테이너를 삽입한다.
|
||||||
|
- 참가자 영역이 숨겨지는 경우와 방장 여부에 따라 UI가 달라져도 광고 영역이 레이아웃을 깨지 않도록 표시 조건을 명확히 정한다.
|
||||||
|
- 배너 크기 계산 시 실제 측정 너비와 90dp 상한을 사용한다.
|
||||||
|
- fragment 종료 또는 dialog dismiss 시 배너 리소스를 해제한다.
|
||||||
|
|
||||||
|
### 4. 콘텐츠 상세 전면 광고 추가
|
||||||
|
- 수정 대상:
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
|
||||||
|
- 진입 경로 근거:
|
||||||
|
- `setupPlayArea()`에서 `binding.ivPlayOrPause.setOnClickListener(playClickAction)`
|
||||||
|
- `binding.llPreview.setOnClickListener(playClickAction)`
|
||||||
|
- 실제 재생은 `playClickAction` 내부의 `AudioContentPlayService` 시작으로 이어진다.
|
||||||
|
- 계획:
|
||||||
|
- `LiveRoomActivity`의 `InterstitialAdLoader` / `InterstitialAdLoadListener` / `InterstitialAdEventListener` 패턴을 재사용한다.
|
||||||
|
- 무료 콘텐츠 재생 또는 미리듣기 클릭 시점에만 광고 노출을 시도한다.
|
||||||
|
- 재생/일시정지 버튼은 서비스 브로드캐스트 상태를 단일 기준으로 삼아, 재생 중이면 무조건 `PAUSE`만 보내고 광고 로직은 타지 않도록 분리한다.
|
||||||
|
- 광고가 없거나 실패하면 즉시 기존 `playClickAction`을 계속 진행한다.
|
||||||
|
- 중복 노출 방지를 위한 1회성 상태를 Activity 생명주기에 맞춰 정의한다.
|
||||||
|
- `onDestroy()`에서 loader, listener, ad 참조를 정리한다.
|
||||||
|
|
||||||
|
### 5. 콘텐츠 상세 배너 추가
|
||||||
|
- 수정 대상:
|
||||||
|
- `app/src/main/res/layout/activity_audio_content_detail.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
|
||||||
|
- 위치:
|
||||||
|
- `ll_previous_next_content` 다음, `tv_scheduled_to_open`/`tv_theme`를 포함한 `RelativeLayout` 이전
|
||||||
|
- 계획:
|
||||||
|
- 현재 스크롤 흐름을 유지하면서 중간 섹션으로 배너를 삽입한다.
|
||||||
|
- `NestedScrollView` 내부 측정 너비 기준으로 adaptive inline banner를 로드한다.
|
||||||
|
- `maxAdHeightDp`는 90으로 제한한다.
|
||||||
|
- Activity 종료 시 배너 `destroy()`를 호출한다.
|
||||||
|
|
||||||
|
## 예상 수정 파일
|
||||||
|
- `docs/20260424_Yandex광고추가구현계획.md`
|
||||||
|
- `app/build.gradle`
|
||||||
|
- `app/src/main/res/layout/fragment_live.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
|
||||||
|
- `app/src/main/res/layout/fragment_live_room_detail.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`
|
||||||
|
- `app/src/main/res/layout/activity_audio_content_detail.xml`
|
||||||
|
- `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
|
||||||
|
|
||||||
|
## 검증 계획
|
||||||
|
- 공통
|
||||||
|
- `./gradlew :app:assembleDebug`
|
||||||
|
- `./gradlew :app:testDebugUnitTest`
|
||||||
|
- 수동 확인
|
||||||
|
- 라이브 탭 진입 후 최근 종료한 라이브와 라이브 다시 듣기 사이에 배너가 보이는지 확인한다.
|
||||||
|
- 라이브 상세 bottom sheet를 열어 참여자 목록과 크리에이터 프로필 사이 배너 위치와 높이를 확인한다.
|
||||||
|
- 콘텐츠 상세에서 무료 콘텐츠 재생과 미리듣기 각각에 대해 전면 광고 시도 후 기존 재생 흐름이 유지되는지 확인한다.
|
||||||
|
- 콘텐츠 상세에서 이전화/다음화와 theme/open 예정 영역 사이 배너 위치와 높이를 확인한다.
|
||||||
|
- 모든 배너가 90dp를 넘지 않는지 레이아웃 검사 또는 화면 캡처로 확인한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-04-24
|
||||||
|
- 무엇: Yandex 광고 추가 작업의 구현 계획 문서를 생성했다.
|
||||||
|
- 왜: 저장소 규칙에 따라 구현 전에 `docs` 아래 계획 문서를 먼저 만들고, 그 문서를 기준으로 범위와 검증 기준을 고정해야 하기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 생성 파일: `docs/20260424_Yandex광고추가구현계획.md`
|
||||||
|
- 근거 파일: `app/build.gradle`, `SodaLiveApp.kt`, `MyPageFragment.kt`, `LiveRoomActivity.kt`, `LiveFragment.kt`, `LiveRoomDetailFragment.kt`, `AudioContentDetailActivity.kt`, 각 대응 XML 레이아웃
|
||||||
|
- 근거 문서: `https://ads.yandex.com/helpcenter/ko/dev/android/adaptive-inline-banner`, `https://ads.yandex.com/helpcenter/ko/dev/android/interstitial`
|
||||||
|
- 결과: 광고 형식, 삽입 위치, 90dp 배너 높이 제한, 예상 수정 파일, 검증 계획을 구현 전에 확정했다.
|
||||||
|
- 2026-04-24
|
||||||
|
- 무엇: AD_UNIT_ID 운영 전략을 기존 Yandex 광고와 같은 `BuildConfig` 방식으로 정리하고, 지면별 분리 여부 판단을 문서에 반영했다.
|
||||||
|
- 왜: 구현 전에 ad unit 관리 방식을 고정해야 이후 코드 반영과 운영 기준이 흔들리지 않고, 지면별 성과 측정 단위도 명확해지기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 기준 패턴: `MyPageFragment`의 inline banner id, `LiveRoomActivity`의 interstitial id
|
||||||
|
- 판단 결과: 이번 작업의 4개 지면은 화면 맥락과 포맷이 달라 모두 별도 AD_UNIT_ID를 사용하는 방향으로 계획을 확정했다.
|
||||||
|
- 결과: `app/build.gradle`의 `buildConfigField`를 지면별로 추가하는 방향이 계획 문서에 반영됐다.
|
||||||
|
- 2026-04-24
|
||||||
|
- 무엇: 계획 문서 기준으로 라이브 탭, 라이브 상세, 콘텐츠 상세의 Yandex 광고 코드를 실제 반영했다.
|
||||||
|
- 왜: 사용자 요청대로 3개 화면의 지정 위치에 배너/전면 광고를 추가하고, 기존 저장소의 Yandex 광고 패턴을 동일하게 확장해야 했기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 수정 파일: `app/build.gradle`, `app/src/main/res/layout/fragment_live.xml`, `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`, `app/src/main/res/layout/fragment_live_room_detail.xml`, `app/src/main/java/kr/co/vividnext/sodalive/live/room/detail/LiveRoomDetailFragment.kt`, `app/src/main/res/layout/activity_audio_content_detail.xml`, `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
|
||||||
|
- BuildConfig 반영: 신규 4개 지면용 `YANDEX_*` ad unit id 필드를 `debug`/`release`에 각각 추가했다.
|
||||||
|
- 라이브 탭: `rv_latest_finished_live_channel`과 `ll_replay_live` 사이에 `BannerAdView`를 추가하고, `LiveFragment`에서 90dp 상한의 adaptive inline banner를 로드하도록 구현했다.
|
||||||
|
- 라이브 상세: `ll_participate_wrapper`와 크리에이터 프로필 블록 사이에 `BannerAdView`를 추가하고, `LiveRoomDetailFragment`에서 90dp 상한의 adaptive inline banner를 로드하도록 구현했다.
|
||||||
|
- 콘텐츠 상세: `ll_previous_next_content`와 theme/open 예정 블록 사이에 `BannerAdView`를 추가하고, `AudioContentDetailActivity`에서 90dp 상한의 adaptive inline banner를 로드하도록 구현했다.
|
||||||
|
- 콘텐츠 상세 전면 광고: 무료 재생 또는 미리듣기 클릭 경로만 interstitial로 감싸고, 광고가 없거나 실패하면 기존 재생 액션을 즉시 이어가도록 구현했다.
|
||||||
|
- 정리 코드: `LiveFragment.onDestroyView()`, `LiveRoomDetailFragment.onDestroyView()`, `AudioContentDetailActivity.onDestroy()`에서 배너/전면 광고 리소스를 정리했다.
|
||||||
|
- 2026-04-24
|
||||||
|
- 무엇: 빌드, 테스트, 린트, 수동 확인 가능 여부를 점검했다.
|
||||||
|
- 왜: 이번 변경은 `BuildConfig`, Kotlin, XML, 화면 생명주기를 함께 건드리므로 최소 컴파일·테스트와 실제 실행 가능 여부를 함께 확인해야 하기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 진단 도구: `lsp_diagnostics`
|
||||||
|
- 진단 결과: `.kt` LSP 서버 미설정으로 `No LSP server configured for extension: .kt`
|
||||||
|
- 실행 명령: `./gradlew :app:assembleDebug`
|
||||||
|
- 실행 결과: `BUILD SUCCESSFUL`
|
||||||
|
- 실행 명령: `./gradlew :app:testDebugUnitTest`
|
||||||
|
- 실행 결과: `BUILD SUCCESSFUL`
|
||||||
|
- 실행 명령: `./gradlew :app:ktlintCheck`
|
||||||
|
- 실행 결과: 실패
|
||||||
|
- 린트 실패 원인: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt:1:1 Package name must not contain underscore`
|
||||||
|
- 린트 판단: 현재 저장소의 기존 패키지 경로 `audio_content` 때문에 발생한 규칙 위반으로, 이번 작업에서 새로 만든 오류는 확인되지 않았다.
|
||||||
|
- 실행 명령: `adb devices`
|
||||||
|
- 실행 결과: 연결 기기 없음
|
||||||
|
- 수동 확인 결과: ADB 연결 기기가 없어 앱 설치 및 실제 광고 노출 경로 수동 검증까지는 진행하지 못했다.
|
||||||
|
- 비고: 신규 ad unit id는 placeholder 문자열로 넣었으므로, 실제 광고 서버 응답 검증은 실 ad unit id 교체 후 추가 확인이 필요하다.
|
||||||
|
- 2026-04-24
|
||||||
|
- 무엇: Oracle 검토 의견을 반영해 배너 높이 상한과 interstitial 종료 경로 안전장치를 보강한 뒤 다시 빌드와 테스트를 확인했다.
|
||||||
|
- 왜: 코드상 `maxAdHeightDp = 90`만으로 끝내지 않고 XML 레벨에서도 90dp 상한을 명시해 두는 편이 안전하고, Activity 종료 중 광고 콜백이 들어와도 재생 동작이 이어지지 않도록 방어해야 하기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 수정 파일: `fragment_live.xml`, `fragment_live_room_detail.xml`, `activity_audio_content_detail.xml`, `AudioContentDetailActivity.kt`
|
||||||
|
- 추가 반영: 각 `BannerAdView`에 `android:maxHeight="90dp"`를 명시했다.
|
||||||
|
- 추가 반영: `AudioContentDetailActivity.continuePendingAudioContentPlayAction()`에서 `isFinishing || isDestroyed`일 때 재생 액션을 중단하도록 보강했다.
|
||||||
|
- 실행 명령: `./gradlew :app:assembleDebug`
|
||||||
|
- 실행 결과: `BUILD SUCCESSFUL`
|
||||||
|
- 실행 명령: `./gradlew :app:testDebugUnitTest`
|
||||||
|
- 실행 결과: `BUILD SUCCESSFUL`
|
||||||
|
- 2026-04-24
|
||||||
|
- 무엇: 콘텐츠 상세의 재생/일시정지 버튼과 전면 광고 게이팅 로직을 사용자 의도에 맞게 분리했다.
|
||||||
|
- 왜: 기존 구현은 pause 아이콘이 보여도 클릭 리스너가 재생 경로를 유지해, 무료 콘텐츠 또는 미리듣기에서 pause 클릭 시 전면 광고가 늦게 뜨는 문제가 있었기 때문이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
|
||||||
|
- 상태 기준: `AudioContentPlayService` 브로드캐스트의 `EXTRA_AUDIO_CONTENT_PLAYING` 값을 단일 기준으로 사용하도록 정리했다.
|
||||||
|
- 버튼 동작: 재생 중이면 `PAUSE`만 보내고, 재생 시작 시점에만 무료/미리듣기 대상 여부를 판단해 interstitial을 시도하도록 분리했다.
|
||||||
|
- 기대 효과: 무료 콘텐츠 또는 유료 콘텐츠 미리듣기에서 “재생 시작 시”에만 전면 광고가 걸리고, pause 클릭은 즉시 pause로 동작한다.
|
||||||
Reference in New Issue
Block a user