Files
daycard-app/app/src/main/java/com/pukka/mediaapp/ui/DetailActivity.kt
2026-07-26 12:43:59 +08:00

545 lines
21 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.pukka.mediaapp.ui
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.os.CountDownTimer
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.bumptech.glide.Glide
import com.pukka.mediaapp.databinding.ActivityDetailBinding
import com.pukka.mediaapp.databinding.DialogPaymentBinding
import com.pukka.mediaapp.model.ContentItem
import com.pukka.mediaapp.network.SignManager
import com.pukka.mediaapp.sdk.JetsenxSdkUtil
import com.pukka.mediaapp.ui.adapter.AppColors
import com.pukka.mediaapp.utils.QRCodeUtil
import com.pukka.mediaapp.utils.FileLogger
import com.pukka.mediaapp.viewmodel.DetailViewModel
import com.pukka.mediaapp.viewmodel.PaymentState
import com.pukka.mediaapp.viewmodel.PaymentViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Locale
/**
* 详情页:海报、标题、评分、演员、简介 + 播放(含微信扫码支付流程)+ 返回。
*
* 完整播放流程(改造后):
* 1. 点击播放 → 采集设备标识 → 检查天卡播放权限
* 2. 有权限 → 调用SDK获取视频地址 → 跳转播放器
* 3. 无权限 → 创建订单 → 展示微信支付二维码 → 轮询支付状态
* 4. 支付成功 → 获取权益 → 初始化SDK → 获取视频地址 → 跳转播放器
* 5. 超时/关闭 → 展示重新生成按钮
*/
class DetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityDetailBinding
private val viewModel: DetailViewModel by viewModels()
private val paymentViewModel: PaymentViewModel by viewModels()
private var paymentDialog: AlertDialog? = null
private var dialogBinding: DialogPaymentBinding? = null
private var contentItem: ContentItem? = null
private var sdkInitialized = false
private var isFetchingVideoUrl = false // 防止重复跳转播放器
private var currentMediaId: String = ""
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
val contentcode = intent.getStringExtra(EXTRA_CONTENTCODE).orEmpty()
val name = intent.getStringExtra(EXTRA_NAME).orEmpty()
currentMediaId = contentcode
binding.tvTitle.text = name
applyTextColors()
binding.btnBack.setOnClickListener { finish() }
binding.btnPlay.setOnClickListener {
Log.d("DetailActivity", "btnPlay clicked! contentcode=$contentcode")
isFetchingVideoUrl = false // 重置标志
startPaymentFlow()
}
observeViewModel()
observePaymentState()
viewModel.loadDetail(contentcode)
}
/** 开始鉴权/支付流程(sign 由 SignManager 管理,无需 App 传设备标识) */
private fun startPaymentFlow() {
// 取消之前的倒计时
countDownTimer?.cancel()
countDownTimer = null
paymentViewModel.startPaymentFlow()
}
private fun applyTextColors() {
binding.tvTitle.setTextColor(AppColors.TEXT_PRIMARY)
binding.tvActor.setTextColor(AppColors.TEXT_SECONDARY)
binding.tvIntro.setTextColor(AppColors.TEXT_TERTIARY)
}
/** 时长格式化:秒 -> "104分钟" 或 "1小时44分钟" */
private fun formatDuration(duration: String?): String? {
if (duration.isNullOrEmpty()) return null
val seconds = duration.toLongOrNull() ?: return null
if (seconds <= 0) return null
val totalMinutes = seconds / 60
if (totalMinutes < 60) return "${totalMinutes}分钟"
val hours = totalMinutes / 60
val mins = totalMinutes % 60
return if (mins > 0) "${hours}小时${mins}分钟" else "${hours}小时"
}
private fun observeViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.detail.collect { item ->
if (item == null) return@collect
contentItem = item
binding.tvTitle.text = item.name
// 元信息行:分类 · 地区 · 年份 · 时长
val metaParts = mutableListOf<String>()
item.firstproject?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
item.secondproject?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
item.originalcountry?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
item.releaseyear?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
formatDuration(item.duration)?.let { metaParts.add(it) }
binding.tvType.text = metaParts.joinToString(" · ")
// 演员:| 分割取前4个
val actors = item.actordisplay?.split("|")?.map { it.trim() }?.filter { it.isNotEmpty() }
binding.tvActor.text = if (!actors.isNullOrEmpty()) "主演: ${actors.take(4).joinToString(" ")}" else ""
binding.tvIntro.text = item.getIntroText()
// 横幅背景图
val backdrop = item.thumbimg?.takeIf { it.isNotEmpty() }
?: item.midimg?.takeIf { it.isNotEmpty() }
?: item.image?.takeIf { it.isNotEmpty() }
if (!backdrop.isNullOrEmpty()) {
Glide.with(this@DetailActivity).load(backdrop).into(binding.ivBackdrop)
}
// 海报
val poster = item.posterimg?.takeIf { it.isNotEmpty() }
?: item.getPosterUrl()
Glide.with(this@DetailActivity).load(poster).into(binding.ivPoster)
}
}
}
}
private fun observePaymentState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
paymentViewModel.state.collect { state ->
FileLogger.log("DetailActivity", "collect state=${state::class.simpleName}")
when (state) {
is PaymentState.Idle -> {}
is PaymentState.CheckingPermission -> {
// 静默鉴权:不弹窗、不提示,直接走后续播放
}
is PaymentState.HasPermission -> {
if (isFetchingVideoUrl) return@collect
isFetchingVideoUrl = true
dismissPaymentDialog()
playWithThirdCardNo(state.thirdCardNo)
}
is PaymentState.CreatingOrder -> {
showPaymentDialog("正在创建天卡订单...")
}
is PaymentState.WaitingForPayment -> {
// 展示二维码
showQrCode(
codeUrl = state.codeUrl,
amount = state.amount,
orderNo = state.orderNo,
expireAt = state.expireAt
)
}
is PaymentState.PaymentSuccess -> {
if (isFetchingVideoUrl) return@collect
isFetchingVideoUrl = true
countDownTimer?.cancel()
dismissPaymentDialog()
playWithThirdCardNo(state.thirdCardNo)
}
is PaymentState.PaymentTimeout -> {
countDownTimer?.cancel()
showPaymentTimeout()
}
is PaymentState.Error -> {
countDownTimer?.cancel()
isFetchingVideoUrl = false
FileLogger.log("DetailActivity", "Error: msg=${state.message} dialogShowing=${paymentDialog?.isShowing}")
if (paymentDialog?.isShowing != true) showPaymentDialog("")
updatePaymentDialog(
errorText = "失败: ${state.message}",
showCloseButton = true
)
}
}
}
}
}
}
/**
* 展示微信支付二维码
*/
private fun showQrCode(codeUrl: String, amount: Int, orderNo: String, expireAt: String) {
val db = dialogBinding ?: showPaymentDialog("").let { dialogBinding } ?: return
// 隐藏加载状态
db.progressBar.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.VISIBLE
db.flQrcode.visibility = View.VISIBLE
db.ivQrcode.visibility = View.GONE
// 金额显示(分转元)
db.tvPayAmount.visibility = View.VISIBLE
db.tvPayAmount.text = "¥${"%.2f".format(amount / 100.0)}"
// 订单号
db.tvPayOrderNo.visibility = View.VISIBLE
db.tvPayOrderNo.text = "订单号: $orderNo"
// 状态文本
db.tvPayStatus.text = "请使用微信扫码支付"
// 按钮:显示取消(用户可随时取消支付)
db.llButtons.visibility = View.GONE
db.btnClose.visibility = View.VISIBLE
db.btnClose.text = "取消支付"
db.btnRegenerate.visibility = View.GONE
// 倒计时
db.tvCountdown.visibility = View.VISIBLE
startCountdown(expireAt)
// 异步生成二维码
lifecycleScope.launch(Dispatchers.IO) {
try {
val bitmap = QRCodeUtil.generateQRCode(codeUrl, 400)
withContext(Dispatchers.Main) {
val currentBinding = dialogBinding ?: return@withContext
currentBinding.pbQrcodeLoading.visibility = View.GONE
currentBinding.ivQrcode.setImageBitmap(bitmap)
currentBinding.ivQrcode.visibility = View.VISIBLE
}
} catch (e: Exception) {
Log.e("DetailActivity", "QR code generation failed", e)
withContext(Dispatchers.Main) {
updatePaymentDialog(
errorText = "二维码生成失败,请重试",
showCloseButton = true
)
}
}
}
}
/**
* 启动倒计时
*/
private fun startCountdown(expireAt: String) {
countDownTimer?.cancel()
val remainMillis = calculateRemainMillis(expireAt)
if (remainMillis <= 0) {
paymentViewModel.cancelPolling()
showPaymentTimeout()
return
}
countDownTimer = object : CountDownTimer(remainMillis, 1000) {
override fun onTick(millisUntilFinished: Long) {
val db = dialogBinding ?: return
val minutes = millisUntilFinished / 60000
val seconds = (millisUntilFinished % 60000) / 1000
db.tvCountdown.text = String.format("%02d:%02d", minutes, seconds)
}
override fun onFinish() {
val db = dialogBinding ?: return
db.tvCountdown.text = "00:00"
paymentViewModel.cancelPolling()
showPaymentTimeout()
}
}.start()
}
/**
* 解析 expireAt 计算剩余时间(毫秒)
*/
private fun calculateRemainMillis(expireAt: String): Long {
if (expireAt.isEmpty()) {
// 默认 15 分钟
return 15 * 60 * 1000L
}
// 尝试多种格式解析
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss"
)
for (pattern in formats) {
try {
val sdf = SimpleDateFormat(pattern, Locale.getDefault())
val expireTime = sdf.parse(expireAt)
if (expireTime != null) {
val remain = expireTime.time - System.currentTimeMillis()
return if (remain > 0) remain else 0
}
} catch (_: Exception) {
// 尝试下一种格式
}
}
// 解析失败,默认 15 分钟
Log.w("DetailActivity", "Failed to parse expireAt: $expireAt, using default 15min")
return 15 * 60 * 1000L
}
/**
* 显示支付超时
*/
private fun showPaymentTimeout() {
val db = dialogBinding ?: return
db.ivQrcode.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.GONE
db.progressBar.visibility = View.GONE
db.tvCountdown.visibility = View.GONE
db.tvPayAmount.visibility = View.GONE
db.llButtons.visibility = View.GONE
db.tvPayStatus.text = "支付超时,请重新生成"
db.btnRegenerate.visibility = View.VISIBLE
db.btnClose.visibility = View.VISIBLE
}
/**
* 用 thirdCardNo 初始化 SDK 并播放:
* 1. 用 thirdCardNo 初始化SDK
* 2. 调用SDK获取视频地址
* 3. 跳转播放器
*/
private fun playWithThirdCardNo(thirdCardNo: String) {
val item = contentItem
if (item == null) {
isFetchingVideoUrl = false
Toast.makeText(this, "内容信息未加载", Toast.LENGTH_SHORT).show()
return
}
if (thirdCardNo.isEmpty()) {
isFetchingVideoUrl = false
Toast.makeText(this, "天卡卡号缺失,请重试", Toast.LENGTH_LONG).show()
return
}
JetsenxSdkUtil.init(this, thirdCardNo, object : JetsenxSdkUtil.InitCallback {
override fun onSuccess(memberCode: String) {
Log.d("DetailActivity", "SDK init success with thirdCardNo: memberCode=$memberCode")
sdkInitialized = true
fetchVideoUrlFromSdk(item)
}
override fun onFail(message: String) {
Log.e("DetailActivity", "SDK init fail with thirdCardNo: $message")
runOnUiThread {
isFetchingVideoUrl = false
SignManager.clearBenefits()
Toast.makeText(this@DetailActivity, "播放SDK初始化失败: $message", Toast.LENGTH_LONG).show()
}
}
})
}
/** 调用SDK获取视频地址并跳转播放器 */
private fun fetchVideoUrlFromSdk(item: ContentItem) {
val parentContentType = item.getSdkParentContentType()
val programCode = item.getVideoCode()
Log.d("DetailActivity", "fetchVideoUrlFromSdk: parentContentType=$parentContentType programCode=$programCode")
if (programCode.isEmpty()) {
runOnUiThread {
isFetchingVideoUrl = false
Toast.makeText(this@DetailActivity, "无法获取节目编码", Toast.LENGTH_LONG).show()
}
return
}
JetsenxSdkUtil.getAllVideoUrl(parentContentType, programCode,
object : JetsenxSdkUtil.VideoUrlCallback {
override fun onSuccess(videoUrl1080: String, videoUrl720: String, isFree: Boolean) {
runOnUiThread {
val videoUrl = if (videoUrl1080.isNotEmpty()) videoUrl1080 else videoUrl720
Log.d("DetailActivity", "获取视频地址成功: $videoUrl (isFree=$isFree)")
if (videoUrl.isEmpty()) {
isFetchingVideoUrl = false
Toast.makeText(this@DetailActivity, "未获取到视频地址", Toast.LENGTH_LONG).show()
return@runOnUiThread
}
dismissPaymentDialog()
// 跳转播放器
val intent = Intent(this@DetailActivity, VideoPlayerActivity::class.java).apply {
putExtra(VideoPlayerActivity.EXTRA_VIDEO_URL, videoUrl)
putExtra(VideoPlayerActivity.EXTRA_TITLE, item.name)
}
startActivity(intent)
}
}
override fun onError(code: String, message: String) {
runOnUiThread {
isFetchingVideoUrl = false
Log.e("DetailActivity", "获取视频地址失败: code=$code msg=$message")
Toast.makeText(this@DetailActivity, "获取视频地址失败: $message", Toast.LENGTH_LONG).show()
}
}
})
}
/** 显示支付对话框 */
private fun showPaymentDialog(statusText: String) {
if (paymentDialog?.isShowing == true) {
updatePaymentDialog(loadingText = statusText)
return
}
dialogBinding = DialogPaymentBinding.inflate(LayoutInflater.from(this))
dialogBinding?.tvPayStatus?.text = statusText
// 重新生成按钮 → 重新发起支付流程
dialogBinding?.btnRegenerate?.setOnClickListener {
countDownTimer?.cancel()
dialogBinding?.btnRegenerate?.visibility = View.GONE
dialogBinding?.btnClose?.visibility = View.GONE
isFetchingVideoUrl = false
startPaymentFlow()
}
// 关闭按钮
dialogBinding?.btnClose?.setOnClickListener {
dismissPaymentDialog()
paymentViewModel.reset()
}
// 保留兼容:确认/取消按钮(默认隐藏)
dialogBinding?.btnCancel?.setOnClickListener {
dismissPaymentDialog()
paymentViewModel.reset()
}
dialogBinding?.btnConfirm?.visibility = View.GONE
dialogBinding?.flQrcode?.visibility = View.GONE
paymentDialog = AlertDialog.Builder(this)
.setView(dialogBinding?.root)
.setCancelable(false)
.create()
paymentDialog?.window?.setBackgroundDrawableResource(android.R.color.transparent)
paymentDialog?.show()
}
/**
* 更新支付对话框状态
* @param loadingText 加载中文本(显示进度条)
* @param errorText 错误文本(显示关闭按钮)
* @param showCloseButton 是否显示关闭按钮
*/
private fun updatePaymentDialog(
loadingText: String? = null,
errorText: String? = null,
showCloseButton: Boolean = false
) {
val db = dialogBinding ?: return
val text = loadingText ?: errorText ?: ""
db.tvPayStatus.text = text
if (loadingText != null) {
// 加载状态:显示进度条 + 取消按钮(始终允许用户取消)
db.progressBar.visibility = View.VISIBLE
db.flQrcode.visibility = View.GONE
db.ivQrcode.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.GONE
db.tvPayAmount.visibility = View.GONE
db.tvCountdown.visibility = View.GONE
db.tvPayOrderNo.visibility = View.GONE
db.llButtons.visibility = View.GONE
db.btnRegenerate.visibility = View.GONE
db.btnClose.visibility = View.VISIBLE
db.btnClose.text = "取消"
} else if (errorText != null || showCloseButton) {
// 错误/完成状态:隐藏进度条,显示关闭按钮
db.progressBar.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.GONE
db.flQrcode.visibility = View.GONE
db.ivQrcode.visibility = View.GONE
db.tvCountdown.visibility = View.GONE
db.llButtons.visibility = View.GONE
db.btnRegenerate.visibility = View.GONE
db.btnClose.visibility = View.VISIBLE
db.btnClose.text = "关闭"
db.tvPayAmount.visibility = View.GONE
db.btnRegenerate.visibility = View.VISIBLE
db.btnRegenerate.text = "重试"
// 订单号
val orderNo = paymentViewModel.orderNo.value
if (orderNo.isNotEmpty()) {
db.tvPayOrderNo.visibility = View.VISIBLE
db.tvPayOrderNo.text = "订单号: $orderNo"
}
}
}
private fun dismissPaymentDialog() {
countDownTimer?.cancel()
countDownTimer = null
paymentViewModel.cancelPolling()
paymentDialog?.dismiss()
paymentDialog = null
dialogBinding = null
}
override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
countDownTimer = null
paymentViewModel.cancelPolling()
dismissPaymentDialog()
}
companion object {
const val EXTRA_CONTENTCODE = "extra_contentcode"
const val EXTRA_NAME = "extra_name"
}
}