diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3596825c..a62d2966 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -113,6 +113,7 @@
+
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveActivity.kt
new file mode 100644
index 00000000..dd2e2f34
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/live/onair/HomeOnAirLiveActivity.kt
@@ -0,0 +1,311 @@
+package kr.co.vividnext.sodalive.v2.live.onair
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.Gravity
+import android.widget.Toast
+import androidx.core.view.isVisible
+import androidx.media3.common.util.UnstableApi
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.gson.Gson
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
+import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
+import kr.co.vividnext.sodalive.base.BaseActivity
+import kr.co.vividnext.sodalive.base.SodaDialog
+import kr.co.vividnext.sodalive.common.Constants
+import kr.co.vividnext.sodalive.common.LoadingDialog
+import kr.co.vividnext.sodalive.common.SharedPreferenceManager
+import kr.co.vividnext.sodalive.common.ToastMessage
+import kr.co.vividnext.sodalive.databinding.ActivityHomeOnAirLiveBinding
+import kr.co.vividnext.sodalive.live.LiveViewModel
+import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
+import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
+import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
+import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
+import kr.co.vividnext.sodalive.mypage.MyPageViewModel
+import kr.co.vividnext.sodalive.mypage.auth.Auth
+import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
+import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
+import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
+import kr.co.vividnext.sodalive.settings.language.LanguageManager
+import kr.co.vividnext.sodalive.settings.language.LocaleHelper
+import kr.co.vividnext.sodalive.splash.SplashActivity
+import kr.co.vividnext.sodalive.user.login.LoginActivity
+import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState
+import kr.co.vividnext.sodalive.v2.live.onair.model.canEnterHomeOnAirLiveRoom
+import kr.co.vividnext.sodalive.v2.live.onair.ui.HomeOnAirLiveAdapter
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+@UnstableApi
+class HomeOnAirLiveActivity : BaseActivity(
+ ActivityHomeOnAirLiveBinding::inflate
+) {
+ private val viewModel: HomeOnAirLiveViewModel by viewModel()
+ private val liveViewModel: LiveViewModel by inject()
+ private val myPageViewModel: MyPageViewModel by inject()
+ private val loadingDialog: LoadingDialog by lazy { LoadingDialog(this, layoutInflater) }
+ private val adapter = HomeOnAirLiveAdapter { enterLiveRoom(it.roomId) }
+ private var isPageLoading = false
+ private var isLiveEntryLoading = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ bindData()
+ viewModel.loadFirstPage()
+ }
+
+ override fun setupView() {
+ binding.toolbar.tvBack.setText(R.string.live_now)
+ binding.toolbar.tvBack.setOnClickListener { finish() }
+ binding.rvHomeOnAirLive.apply {
+ layoutManager = LinearLayoutManager(this@HomeOnAirLiveActivity)
+ adapter = this@HomeOnAirLiveActivity.adapter
+ addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (dy > 0 && !recyclerView.canScrollVertically(1)) {
+ viewModel.loadNextPage()
+ }
+ }
+ })
+ }
+ }
+
+ private fun bindData() {
+ viewModel.onAirLiveStateLiveData.observe(this) { state ->
+ when (state) {
+ HomeOnAirLivePageUiState.Loading -> Unit
+ HomeOnAirLivePageUiState.Empty -> showEmpty()
+ is HomeOnAirLivePageUiState.Error -> showEmpty()
+ is HomeOnAirLivePageUiState.Content -> {
+ binding.rvHomeOnAirLive.isVisible = true
+ binding.tvHomeOnAirLiveEmpty.isVisible = false
+ adapter.submitItems(state.content.items)
+ state.content.paginationErrorMessage?.let { message ->
+ Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
+ viewModel.consumePaginationErrorMessage()
+ }
+ }
+ }
+ }
+ viewModel.isLoading.observe(this) { isLoading ->
+ isPageLoading = isLoading
+ updateLoadingDialog()
+ }
+ viewModel.toastLiveData.observe(this) { toastMessage ->
+ toastMessage?.let(::showToast)
+ }
+ liveViewModel.isLoading.observe(this) { isLoading ->
+ isLiveEntryLoading = isLoading
+ updateLoadingDialog()
+ }
+ liveViewModel.toastLiveData.observe(this) { message ->
+ message?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
+ }
+ }
+
+ private fun showEmpty() {
+ binding.rvHomeOnAirLive.isVisible = false
+ binding.tvHomeOnAirLiveEmpty.isVisible = true
+ adapter.submitItems(emptyList())
+ }
+
+ private fun enterLiveRoom(roomId: Long) {
+ ensureLoginAndAdultAuth(isAdult = false) {
+ liveViewModel.getRoomDetail(roomId) { roomDetail ->
+ if (!canEnterHomeOnAirLiveRoom(roomDetail)) {
+ Toast.makeText(applicationContext, R.string.common_error_unknown, Toast.LENGTH_LONG).show()
+ return@getRoomDetail
+ }
+
+ ensureLoginAndAdultAuth(isAdult = roomDetail.isAdult) {
+ enterLiveRoom(roomId, roomDetail)
+ }
+ }
+ }
+ }
+
+ private fun enterLiveRoom(roomId: Long, roomDetail: GetRoomDetailResponse) {
+ startService(
+ Intent(applicationContext, AudioContentPlayService::class.java).apply {
+ action = AudioContentPlayService.MusicAction.STOP.name
+ }
+ )
+ startService(
+ Intent(applicationContext, AudioContentPlayerService::class.java).apply {
+ action = "STOP_SERVICE"
+ }
+ )
+
+ val onEnterRoomSuccess = {
+ runOnUiThread {
+ startActivity(
+ Intent(applicationContext, LiveRoomActivity::class.java).apply {
+ putExtra(Constants.EXTRA_ROOM_ID, roomId)
+ }
+ )
+ }
+ }
+
+ if (roomDetail.manager.id == SharedPreferenceManager.userId) {
+ liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
+ } else if (roomDetail.price == 0 || roomDetail.isPaid) {
+ if (roomDetail.isPrivateRoom) {
+ showPasswordDialog(roomId, can = 0, onEnterRoomSuccess = onEnterRoomSuccess)
+ } else {
+ liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
+ }
+ } else {
+ showPaidLiveEntryDialog(
+ roomId = roomId,
+ beginDateTimeUtc = roomDetail.beginDateTimeUtc,
+ price = roomDetail.price,
+ isPrivateRoom = roomDetail.isPrivateRoom,
+ onEnterRoomSuccess = onEnterRoomSuccess
+ )
+ }
+ }
+
+ private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) {
+ if (SharedPreferenceManager.token.isBlank()) {
+ showLoginActivity()
+ return
+ }
+
+ if (isAdult) {
+ val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
+ if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
+ SodaDialog(
+ activity = this,
+ layoutInflater = layoutInflater,
+ title = getString(R.string.auth_title),
+ desc = getString(R.string.auth_desc_live),
+ confirmButtonTitle = getString(R.string.auth_go),
+ confirmButtonClick = { startAuthFlow() },
+ cancelButtonTitle = getString(R.string.cancel),
+ cancelButtonClick = {},
+ descGravity = Gravity.CENTER
+ ).show(screenWidth)
+ return
+ }
+
+ if (!SharedPreferenceManager.isAdultContentVisible) {
+ startActivity(
+ Intent(applicationContext, ContentSettingsActivity::class.java).apply {
+ putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
+ }
+ )
+ return
+ }
+ }
+
+ onAuthed()
+ }
+
+ private fun showLoginActivity() {
+ if (SharedPreferenceManager.token.isBlank()) {
+ startActivity(
+ Intent(applicationContext, LoginActivity::class.java).apply {
+ putExtra(Constants.EXTRA_DATA, intent.extras)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
+ )
+ }
+ }
+
+ private fun startAuthFlow() {
+ Auth.auth(this, this) { json ->
+ val bootpayResponse = Gson().fromJson(json, BootpayResponse::class.java)
+ val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
+ runOnUiThread {
+ myPageViewModel.authVerify(request) {
+ startActivity(
+ Intent(applicationContext, SplashActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ )
+ finish()
+ }
+ }
+ }
+ }
+
+ private fun showPasswordDialog(roomId: Long, can: Int, onEnterRoomSuccess: () -> Unit) {
+ LiveRoomPasswordDialog(
+ activity = this,
+ layoutInflater = layoutInflater,
+ can = can,
+ confirmButtonClick = { password ->
+ liveViewModel.enterRoom(
+ roomId = roomId,
+ onSuccess = onEnterRoomSuccess,
+ password = password
+ )
+ }
+ ).show(screenWidth)
+ }
+
+ private fun showPaidLiveEntryDialog(
+ roomId: Long,
+ beginDateTimeUtc: String,
+ price: Int,
+ isPrivateRoom: Boolean,
+ onEnterRoomSuccess: () -> Unit
+ ) {
+ if (isPrivateRoom) {
+ showPasswordDialog(roomId, can = price, onEnterRoomSuccess = onEnterRoomSuccess)
+ return
+ }
+
+ val locale = Locale(LanguageManager.getEffectiveLanguage(this))
+ val wrappedContext = LocaleHelper.wrap(this)
+ val beginDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }.parse(beginDateTimeUtc) ?: return
+ val now = Date()
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd, HH:mm", locale)
+ val diffTime = now.time - beginDate.time
+ val hours = (diffTime / (1000 * 60 * 60)).toInt()
+ val mins = (diffTime / (1000 * 60)).toInt() % 60
+
+ LivePaymentDialog(
+ activity = this,
+ layoutInflater = layoutInflater,
+ title = wrappedContext.getString(R.string.live_paid_title),
+ startDateTime = if (hours >= 1) dateFormat.format(beginDate) else null,
+ nowDateTime = if (hours >= 1) dateFormat.format(now) else null,
+ desc = wrappedContext.getString(R.string.live_paid_desc, price),
+ desc2 = if (hours >= 1) wrappedContext.getString(R.string.live_paid_warning, hours, mins) else null,
+ confirmButtonTitle = wrappedContext.getString(R.string.live_paid_confirm),
+ confirmButtonClick = { liveViewModel.enterRoom(roomId, onEnterRoomSuccess) },
+ cancelButtonTitle = wrappedContext.getString(R.string.cancel),
+ cancelButtonClick = {}
+ ).show(screenWidth)
+ }
+
+ private fun showToast(toastMessage: ToastMessage) {
+ toastMessage.message?.let { message -> showToast(message) }
+ ?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
+ }
+
+ private fun updateLoadingDialog() {
+ if (isPageLoading || isLiveEntryLoading) {
+ loadingDialog.show(screenWidth)
+ } else {
+ loadingDialog.dismiss()
+ }
+ }
+
+ companion object {
+ fun newIntent(context: Context): Intent = Intent(context, HomeOnAirLiveActivity::class.java)
+ }
+}