初始化橘视厅酒店版Android项目
139
app/build.gradle
Normal file
@@ -0,0 +1,139 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.pukka.mediaapp'
|
||||
compileSdk app_android.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.pukka.mediaapp"
|
||||
minSdk app_android.minSdkVersion
|
||||
targetSdk app_android.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
multiDexEnabled true
|
||||
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a"
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file('../key/jiecheng.jks')
|
||||
storePassword 'abc@123'
|
||||
keyAlias 'key0'
|
||||
keyPassword 'abc@123'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
signingConfig signingConfigs.release
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
jniLibs.srcDirs = ['libs']
|
||||
}
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
// 确保 .so 文件不解压,避免对齐问题
|
||||
jniLibs {
|
||||
// useLegacyPackaging=true:.so 安装时解压到文件系统,由系统处理页对齐
|
||||
// 绕过 Android 15 的 16KB 对齐要求(Fresco 的 .so 为 4KB 对齐)
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
resources {
|
||||
excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt', 'META-INF/license.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'META-INF/notice.txt', 'META-INF/ASL2.0', 'META-INF/*.kotlin_module']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 基础依赖(来自根 config.gradle,与 libcore/libdata 保持一致)
|
||||
app_base_dependencies.each { k, v -> implementation v }
|
||||
|
||||
// 模块依赖(复用 jiecheng 基础设施)
|
||||
implementation project(':libcore')
|
||||
implementation project(':libdata')
|
||||
implementation project(':mediaplayertv')
|
||||
|
||||
// Kotlin & AndroidX
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
|
||||
// multidex(Android 4.4 兼容)
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
|
||||
// Retrofit(okhttp 由 libcore 传递 3.12,兼容 Android 4.4)
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// Gson
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
|
||||
// Glide(图片加载)
|
||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||
|
||||
// Coroutines
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
// JetsenxSdk + NewTVSDK(使用 api-future 生产域名)
|
||||
implementation files('libs/JetsenxSdk-api-future-release-v1.23.aar')
|
||||
implementation files('libs/NewTVSDK-v4.0.3-app-lite.jar')
|
||||
|
||||
// ExoPlayer
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.19.1'
|
||||
|
||||
// commons-codec(SDK 依赖)
|
||||
implementation 'commons-codec:commons-codec:1.15'
|
||||
|
||||
// 火山引擎 TLS 日志库(JetsenxSdk 内部依赖)
|
||||
implementation('io.github.volcengine-tls:tls-android-producer:2.0.2-api16') {
|
||||
exclude group: 'com.google.guava', module: 'guava'
|
||||
exclude group: 'com.google.guava', module: 'guava-jdk5'
|
||||
exclude group: 'com.google.guava', module: 'guava-android'
|
||||
exclude group: 'com.google.guava', module: 'listenablefuture'
|
||||
}
|
||||
implementation 'net.jpountz.lz4:lz4:1.3.0'
|
||||
implementation 'org.slf4j:slf4j-android:1.7.36'
|
||||
|
||||
// ZXing 二维码生成
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
|
||||
// OAID(设备识别)
|
||||
implementation 'com.github.gzu-liyujiang:Android_CN_OAID:4.1.3'
|
||||
}
|
||||
BIN
app/libs/JetsenxSdk-api-future-release-v1.23.aar
Executable file
BIN
app/libs/NewTVSDK-v4.0.3-app-lite.jar
Executable file
BIN
app/libs/arm64-v8a/libnewtvsdk.so
Executable file
BIN
app/libs/armeabi-v7a/libnewtvsdk.so
Executable file
5
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
-keep class com.pukka.mediaapp.model.** { *; }
|
||||
-keepclassmembers,allowobfuscation class * {
|
||||
@com.google.gson.annotations.SerializedName <fields>;
|
||||
}
|
||||
56
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".MediaApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MediaApp"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".ui.ActivationActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.HomeActivity"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ListActivity"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.SearchActivity"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.DetailActivity"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.VideoPlayerActivity"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
41
app/src/main/java/com/pukka/mediaapp/MediaApp.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.pukka.mediaapp
|
||||
|
||||
import android.app.Application
|
||||
import com.pukka.libcore.fresco.FrescoInit
|
||||
import com.pukka.libcore.utils.ContextUtils
|
||||
import com.pukka.mediaapp.config.AppConfiguration
|
||||
import com.pukka.mediaapp.config.DayCardConfig
|
||||
import com.pukka.mediaapp.network.SignManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MediaApp : Application() {
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextUtils.init(this)
|
||||
// 初始化 Fresco(TopView 等 SimpleDraweeView 依赖)
|
||||
FrescoInit.initFrescoConfig()
|
||||
AppConfiguration.init(this)
|
||||
SignManager.init(this)
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
/** 每 30 分钟心跳续期 sign,防止过期 */
|
||||
private fun startHeartbeat() {
|
||||
appScope.launch {
|
||||
while (isActive) {
|
||||
delay(DayCardConfig.HEARTBEAT_INTERVAL)
|
||||
if (SignManager.hasSign()) {
|
||||
SignManager.heartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/pukka/mediaapp/config/ApiConfig.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.pukka.mediaapp.config
|
||||
|
||||
object ApiConfig {
|
||||
/** 媒资接口基础地址(现网环境) */
|
||||
const val BASE_URL = "https://servhub-ep03.future.jetsentv.com/"
|
||||
|
||||
/** 接口路径前缀 */
|
||||
const val PATH_PREFIX = "/IPTVPortalInterface"
|
||||
|
||||
/** 固定凭证 - sptoken */
|
||||
const val SP_TOKEN = "9006874392099a89506142c6df9e3314"
|
||||
|
||||
/** 固定凭证 - portalcode */
|
||||
const val PORTAL_CODE = "PCODE-JC"
|
||||
|
||||
/** 签名密钥 */
|
||||
const val SIGN_KEY = "alltvjs"
|
||||
|
||||
/** 渠道号 */
|
||||
const val CHANNEL_ID = "001"
|
||||
|
||||
/** 设备标识(测试用) */
|
||||
const val STB_ID = "test_stb_001"
|
||||
|
||||
/** 接口路径 */
|
||||
object Path {
|
||||
const val LAYOUT_NAV = "layout/cornerLayoutNav.do"
|
||||
const val LAYOUT = "layout/layout.do"
|
||||
const val TOP_BUTTON = "layout/topButton.do"
|
||||
const val SHORT_VIDEO = "layout/shortVideo.do"
|
||||
const val CONTENT_LIST = "content/getcontentlist.do"
|
||||
const val TAGS_LIST = "content/gettagslist.do"
|
||||
const val SYSTEM = "content/getsystem.do"
|
||||
const val SEARCH = "search/getsearchlist.do"
|
||||
const val SEARCH_SUGGEST = "search/getSuggestByTSG.do"
|
||||
const val FILTER = "search/filterContentList.do"
|
||||
const val SERVICE_LIST = "service/getservicelist.do"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.pukka.mediaapp.config
|
||||
|
||||
import android.content.Context
|
||||
import com.pukka.mediaapp.utils.DeviceInfoUtil
|
||||
|
||||
/**
|
||||
* 应用配置(从生产项目移植,适配 MediaApp)
|
||||
*/
|
||||
class AppConfiguration private constructor(private val appContext: Context) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: AppConfiguration? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (instance == null) {
|
||||
synchronized(this) {
|
||||
if (instance == null) {
|
||||
instance = AppConfiguration(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(): AppConfiguration {
|
||||
return instance ?: throw IllegalStateException("AppConfiguration not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
/** 渠道标识(MediaApp 无渠道前缀,激活码为纯5位数字) */
|
||||
val channelId: String = ""
|
||||
|
||||
/** 是否触屏模式(MediaApp 为 TV 应用,始终 false) */
|
||||
val isTouchMode: Boolean = false
|
||||
|
||||
/** 设备唯一标识 */
|
||||
fun getmDeviceUid(): String {
|
||||
return DeviceInfoUtil.getDeviceIdentity(appContext).androidId ?: ""
|
||||
}
|
||||
}
|
||||
45
app/src/main/java/com/pukka/mediaapp/config/DayCardConfig.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.pukka.mediaapp.config
|
||||
|
||||
/**
|
||||
* 天卡 API 配置(对齐生产方案:单 sign 头鉴权)
|
||||
*/
|
||||
object DayCardConfig {
|
||||
/** 天卡 API 基础地址 */
|
||||
const val BASE_URL = "http://172.16.100.128:8084/"
|
||||
|
||||
/** 激活码长度(管理后台生成的 5 位激活码) */
|
||||
const val ACTIVATION_CODE_LENGTH = 5
|
||||
|
||||
/** 鉴权头名称(单个 sign 头,由 init 下发) */
|
||||
const val SIGN_HEADER = "sign"
|
||||
|
||||
/** 轮询间隔(毫秒) */
|
||||
const val POLL_INTERVAL = 2000L
|
||||
|
||||
/** 轮询超时(毫秒)— 15 分钟 */
|
||||
const val POLL_TIMEOUT = 900000L
|
||||
|
||||
/** 心跳间隔(毫秒)— 30 分钟,防止 sign 过期 */
|
||||
const val HEARTBEAT_INTERVAL = 30 * 60 * 1000L
|
||||
|
||||
/** sign 主动刷新阈值(毫秒)— 生成 20 小时后刷新(sign 有效期 24h) */
|
||||
const val SIGN_REFRESH_THRESHOLD = 20 * 60 * 60 * 1000L
|
||||
|
||||
/** sign 有效期(毫秒)— 24 小时 */
|
||||
const val SIGN_TTL = 24 * 60 * 60 * 1000L
|
||||
|
||||
object Path {
|
||||
/** 设备初始化(唯一不需要 sign 的接口) */
|
||||
const val INIT = "api/jst/init"
|
||||
/** 查询天卡权益(播放鉴权,返回 thirdCardNo) */
|
||||
const val CHECK_BENEFITS = "api/jst/checkBenefits"
|
||||
/** 创建天卡订单(deviceId 从 sign 解析,不传设备标识) */
|
||||
const val DAYCARD_CREATE = "api/jst/daycard/create"
|
||||
/** 查询订单状态 */
|
||||
const val DAYCARD_STATUS = "api/jst/daycard/status/{orderNo}"
|
||||
/** 心跳续期 */
|
||||
const val HEARTBEAT = "api/jst/heartBeat"
|
||||
/** 刷新 sign */
|
||||
const val REFRESH_SIGN = "api/jst/refreshSign"
|
||||
}
|
||||
}
|
||||
234
app/src/main/java/com/pukka/mediaapp/model/Models.kt
Normal file
@@ -0,0 +1,234 @@
|
||||
package com.pukka.mediaapp.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// ==================== 首页导航 (对应 GetLayoutNavBean) ====================
|
||||
|
||||
data class LayoutNavResponse(
|
||||
val data: List<NavGroup>? = null,
|
||||
val resultcode: String = "",
|
||||
val message: String = "",
|
||||
val nowtime: String = ""
|
||||
)
|
||||
|
||||
data class NavGroup(
|
||||
val navList: List<NavItem>? = null,
|
||||
val cornerUrl: String? = null,
|
||||
val cornerType: String? = null,
|
||||
val navColor: String? = null
|
||||
)
|
||||
|
||||
data class NavItem(
|
||||
val navname: String = "",
|
||||
val navid: String = "",
|
||||
val type: Int = 0,
|
||||
val sort: Int = 0,
|
||||
val isFocus: String = "0",
|
||||
val showType: Int = 0,
|
||||
val pageCode: String = "",
|
||||
val navAimage: String = "",
|
||||
val navBimage: String = "",
|
||||
val navFimage: String = "",
|
||||
val bg_imgurl: String = "",
|
||||
val game_code: String = "",
|
||||
val game_id: Int = 0,
|
||||
val game_name: String = ""
|
||||
)
|
||||
|
||||
// ==================== 二级布局 (对应 GetLayoutBean) ====================
|
||||
|
||||
data class LayoutResponse(
|
||||
val data: List<LayoutSection>? = null,
|
||||
val resultcode: String = "",
|
||||
val message: String = "",
|
||||
val navid: String = "",
|
||||
val navname: String = "",
|
||||
val nowtime: String = ""
|
||||
)
|
||||
|
||||
data class LayoutSection(
|
||||
val type: String = "",
|
||||
val title: String = "",
|
||||
val sort: String = "",
|
||||
val titleShow: String = "",
|
||||
val titleicon: String = "",
|
||||
val titleid: Int = 0,
|
||||
val titlecode: String = "",
|
||||
val bgPicture: String? = null,
|
||||
val seasonCode: String? = null,
|
||||
val seasonName: String? = null,
|
||||
val extraData: List<ContentItem>? = null
|
||||
)
|
||||
|
||||
// ==================== 内容列表 (对应 ContentListBean) ====================
|
||||
|
||||
data class ContentListResponse(
|
||||
val result: List<ContentItem>? = null,
|
||||
val pagetotal: Int = 0,
|
||||
val sizetotal: Int = 0,
|
||||
val pageindex: Int = 0,
|
||||
val pagesize: Int = 0,
|
||||
val resultcode: String = "",
|
||||
val resultdesc: String = "",
|
||||
val nowtime: String = ""
|
||||
)
|
||||
|
||||
// ==================== 搜索结果 (对应 SearchResultBean) ====================
|
||||
|
||||
data class SearchResultResponse(
|
||||
val result: List<ContentItem>? = null,
|
||||
val pagetotal: Int = 0,
|
||||
val sizetotal: Int = 0,
|
||||
val pageindex: Int = 0,
|
||||
val pagesize: Int = 0,
|
||||
val resultcode: String = "",
|
||||
val resultdesc: String = "",
|
||||
val nowtime: String = ""
|
||||
)
|
||||
|
||||
// ==================== 内容项 (统一用于布局/列表/搜索) ====================
|
||||
|
||||
/**
|
||||
* 内容项 - 合并了 GetLayoutBean.ExtraDataBean 和 ContentListBean.ResultEntity 的字段
|
||||
* 布局接口返回的字段: image, contentcode, name, isFree, cornerRightUp 等
|
||||
* 列表/搜索接口返回的字段: posterimg, midimg, thumbimg, contentcode, name, score, actordisplay, description, isFree 等
|
||||
*/
|
||||
data class ContentItem(
|
||||
val contentcode: String = "",
|
||||
val code: String = "",
|
||||
val name: String = "",
|
||||
val type: String = "",
|
||||
val title: String = "",
|
||||
val image: String = "",
|
||||
val focusImage: String = "",
|
||||
val cornerUrl: String? = null,
|
||||
val cornerRightUp: String? = null,
|
||||
val cornerRightDown: String? = null,
|
||||
val cornerLeftUp: String? = null,
|
||||
val cornerLeftDown: String? = null,
|
||||
val isFree: Int = 0,
|
||||
val score: String? = null,
|
||||
val actordisplay: String? = null,
|
||||
val description: String? = null,
|
||||
val contenttype: String = "",
|
||||
val firstproject: String? = null,
|
||||
val secondproject: String? = null,
|
||||
val thirdproject: String? = null,
|
||||
val originalcountry: String? = null,
|
||||
val releaseyear: String? = null,
|
||||
val posterimg: String? = null,
|
||||
val midimg: String? = null,
|
||||
val midimg2: String? = null,
|
||||
val thumbimg: String? = null,
|
||||
val cornerMarkPic: String? = null,
|
||||
val duration: String? = null,
|
||||
val episodecount: Int = 0,
|
||||
val episodeindex: String? = null,
|
||||
val parentContentType: String? = null,
|
||||
val categoryname: String? = null,
|
||||
val subcontents: List<SubContentItem>? = null,
|
||||
val reserved1: String? = null,
|
||||
val playurl: String = "",
|
||||
val positionid: String = "",
|
||||
val param: String = "",
|
||||
val showtype: String = "",
|
||||
val mediaResolution: String = "",
|
||||
val mgjscode: String? = null,
|
||||
val isOverlays: Int = 0
|
||||
) {
|
||||
/** 获取海报图片URL - 布局接口用image,列表/搜索用posterimg/midimg */
|
||||
fun getPosterUrl(): String {
|
||||
return when {
|
||||
image.isNotEmpty() -> image
|
||||
!posterimg.isNullOrEmpty() -> posterimg!!
|
||||
!midimg.isNullOrEmpty() -> midimg!!
|
||||
!thumbimg.isNullOrEmpty() -> thumbimg!!
|
||||
!cornerRightUp.isNullOrEmpty() -> cornerRightUp!!
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取评分 */
|
||||
fun getScoreText(): String {
|
||||
return score?.takeIf { it.isNotEmpty() } ?: ""
|
||||
}
|
||||
|
||||
/** 获取演员 */
|
||||
fun getActorText(): String {
|
||||
return actordisplay?.takeIf { it.isNotEmpty() } ?: ""
|
||||
}
|
||||
|
||||
/** 获取简介 */
|
||||
fun getIntroText(): String {
|
||||
return description?.takeIf { it.isNotEmpty() } ?: ""
|
||||
}
|
||||
|
||||
/** 获取类型文本 */
|
||||
fun getTypeText(): String {
|
||||
return when {
|
||||
!firstproject.isNullOrEmpty() -> firstproject!!
|
||||
!originalcountry.isNullOrEmpty() -> originalcountry!!
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否VIP内容 (isFree=0 表示收费) */
|
||||
fun isVip(): Boolean = isFree == 0
|
||||
|
||||
/** 是否电影类型(无子集或分类名包含"电影") */
|
||||
fun isFilmType(): Boolean {
|
||||
if (!categoryname.isNullOrEmpty() && categoryname!!.contains("电影")) return true
|
||||
return subcontents.isNullOrEmpty()
|
||||
}
|
||||
|
||||
/** 获取SDK所需的节目编码(电影用contentcode,电视剧用第一集contentcode) */
|
||||
fun getVideoCode(): String {
|
||||
return if (isFilmType()) {
|
||||
contentcode
|
||||
} else {
|
||||
subcontents?.firstOrNull()?.contentcode ?: contentcode
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取SDK所需的parentContentType */
|
||||
fun getSdkParentContentType(): String {
|
||||
return if (isFilmType()) {
|
||||
parentContentType ?: ""
|
||||
} else {
|
||||
subcontents?.firstOrNull()?.parentContentType ?: parentContentType ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 子集(剧集)数据 */
|
||||
data class SubContentItem(
|
||||
val contentcode: String = "",
|
||||
val name: String = "",
|
||||
val parentContentType: String? = null,
|
||||
val episodeindex: String? = null,
|
||||
val duration: String? = null
|
||||
)
|
||||
|
||||
// ==================== 栏目标签 (对应 GettagslistBean) ====================
|
||||
|
||||
data class TagsListResponse(
|
||||
val result: List<TagItem>? = null,
|
||||
val pagetotal: Int = 0,
|
||||
val sizetotal: Int = 0,
|
||||
val pageindex: Int = 0,
|
||||
val pagesize: Int = 0,
|
||||
val resultcode: String = "",
|
||||
val resultdesc: String = ""
|
||||
)
|
||||
|
||||
data class TagItem(
|
||||
val id: Int = 0,
|
||||
val name: String = "",
|
||||
val code: String = "",
|
||||
val type: Int = 0,
|
||||
val parent_id: Int = 0,
|
||||
val big_project: String = "",
|
||||
val sequence: Int = 0,
|
||||
val epg_type: Int = 0,
|
||||
val subject_type: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.pukka.mediaapp.model.daycard
|
||||
|
||||
/**
|
||||
* 统一响应基础(兼容 code=0 和 code=200)
|
||||
*
|
||||
* 子类不再重复声明 code/msg,避免 data class 的 override 生成同名 backing field
|
||||
* 导致 Gson 报 "declares multiple JSON fields named 'code'"。
|
||||
* Gson 通过反射回填父类字段。
|
||||
*/
|
||||
open class BaseResponse(
|
||||
open val code: Int = 0,
|
||||
open val msg: String = ""
|
||||
) {
|
||||
val isSuccess: Boolean get() = code == 0 || code == 200
|
||||
}
|
||||
|
||||
// ===== 1. 设备初始化 =====
|
||||
|
||||
/** init 请求体(activationCode 为管理后台生成的 5 位激活码) */
|
||||
data class InitRequest(
|
||||
val activationCode: String,
|
||||
val mac: String?,
|
||||
val wifiMac: String? = null,
|
||||
val deviceSn: String?,
|
||||
val deviceUid: String?,
|
||||
val androidId: String? = null,
|
||||
val manufacturer: String? = null,
|
||||
val model: String? = null,
|
||||
val brand: String? = null,
|
||||
val verCode: String? = null,
|
||||
val verName: String? = null
|
||||
)
|
||||
|
||||
/** init 响应(唯一不需要 sign 的接口) */
|
||||
data class InitResponse(
|
||||
val sign: String = "",
|
||||
val data: InitData? = null
|
||||
) : BaseResponse()
|
||||
|
||||
data class InitData(
|
||||
val device: DeviceInfo? = null
|
||||
)
|
||||
|
||||
data class DeviceInfo(
|
||||
val deviceId: Long = 0,
|
||||
val roomNo: String? = null
|
||||
)
|
||||
|
||||
// ===== 2. 查询天卡权益(播放鉴权) =====
|
||||
|
||||
/** checkBenefits 响应:返回 status + thirdCardNo */
|
||||
data class CheckBenefitsResponse(
|
||||
val data: BenefitsData? = null
|
||||
) : BaseResponse() {
|
||||
val status: String get() = data?.status ?: ""
|
||||
/** 是否有天卡权益(status=ACTIVE) */
|
||||
val isActive: Boolean get() = status == "ACTIVE"
|
||||
val thirdCardNo: String get() = data?.thirdCardNo ?: ""
|
||||
val cardNo: String get() = data?.cardNo ?: ""
|
||||
val expireAt: String get() = data?.expireAt ?: ""
|
||||
}
|
||||
|
||||
data class BenefitsData(
|
||||
/** ACTIVE(有权益)/ UNBOUND(无权益) */
|
||||
val status: String = "",
|
||||
val thirdCardNo: String = "",
|
||||
val cardNo: String = "",
|
||||
val expireAt: String = ""
|
||||
)
|
||||
|
||||
// ===== 3. 创建天卡订单 =====
|
||||
|
||||
/** daycard/create 响应(无需请求体,deviceId 从 sign 解析) */
|
||||
data class CreateOrderResponse(
|
||||
val data: CreateOrderData? = null
|
||||
) : BaseResponse() {
|
||||
val orderNo: String get() = data?.orderNo ?: ""
|
||||
val status: String get() = data?.status ?: ""
|
||||
val amount: Int get() = data?.amount ?: 0 // 金额(分)
|
||||
val cardNo: String get() = data?.cardNo ?: ""
|
||||
val codeUrl: String get() = data?.codeUrl ?: "" // 微信支付二维码链接
|
||||
val expireAt: String get() = data?.expireAt ?: ""
|
||||
val deviceId: Long get() = data?.deviceId ?: 0L
|
||||
}
|
||||
|
||||
data class CreateOrderData(
|
||||
val orderNo: String = "",
|
||||
val status: String = "", // CREATED
|
||||
val amount: Int = 0, // 金额(分)
|
||||
val cardNo: String = "",
|
||||
val codeUrl: String = "", // 微信支付二维码链接
|
||||
val expireAt: String = "",
|
||||
val deviceId: Long = 0
|
||||
)
|
||||
|
||||
// ===== 4. 查询订单状态 =====
|
||||
|
||||
/** daycard/status/{orderNo} 响应 */
|
||||
data class OrderStatusResponse(
|
||||
val data: OrderStatusData? = null
|
||||
) : BaseResponse() {
|
||||
val status: String get() = data?.status ?: ""
|
||||
val paidAt: String get() = data?.paidAt ?: ""
|
||||
val codeUrl: String get() = data?.codeUrl ?: ""
|
||||
val amount: Int get() = data?.amount ?: 0
|
||||
val payChannel: String get() = data?.payChannel ?: ""
|
||||
}
|
||||
|
||||
data class OrderStatusData(
|
||||
val orderNo: String = "",
|
||||
/** CREATED / PAYING / PAID / CANCELLED */
|
||||
val status: String = "",
|
||||
val paidAt: String? = null,
|
||||
val codeUrl: String? = null,
|
||||
val amount: Int = 0,
|
||||
val payChannel: String? = null
|
||||
)
|
||||
|
||||
// ===== 5. 心跳续期 =====
|
||||
|
||||
/** heartBeat 响应 */
|
||||
class HeartbeatResponse : BaseResponse()
|
||||
|
||||
// ===== 6. 刷新 sign =====
|
||||
|
||||
/** refreshSign 响应 */
|
||||
data class RefreshSignResponse(
|
||||
val data: RefreshSignData? = null
|
||||
) : BaseResponse() {
|
||||
val newSign: String get() = data?.sign ?: ""
|
||||
}
|
||||
|
||||
data class RefreshSignData(
|
||||
val sign: String = ""
|
||||
)
|
||||
29
app/src/main/java/com/pukka/mediaapp/network/ApiService.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.pukka.mediaapp.network
|
||||
|
||||
import com.pukka.mediaapp.model.*
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface ApiService {
|
||||
|
||||
/** 首页导航 */
|
||||
@POST
|
||||
suspend fun getLayoutNav(@Url url: String, @Body params: Map<String, String>): LayoutNavResponse
|
||||
|
||||
/** 二级页面布局 */
|
||||
@POST
|
||||
suspend fun getLayout(@Url url: String, @Body params: Map<String, String>): LayoutResponse
|
||||
|
||||
/** 内容列表 */
|
||||
@POST
|
||||
suspend fun getContentList(@Url url: String, @Body params: Map<String, String>): ContentListResponse
|
||||
|
||||
/** 搜索 */
|
||||
@POST
|
||||
suspend fun search(@Url url: String, @Body params: Map<String, String>): SearchResultResponse
|
||||
|
||||
/** 栏目标签 */
|
||||
@POST
|
||||
suspend fun getTagsList(@Url url: String, @Body params: Map<String, String>): TagsListResponse
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.pukka.mediaapp.network
|
||||
|
||||
import com.pukka.mediaapp.model.daycard.*
|
||||
import retrofit2.http.*
|
||||
|
||||
/**
|
||||
* 天卡 API(对齐生产方案:单 sign 头鉴权)
|
||||
*
|
||||
* 除 init 外,所有接口由 SignInterceptor 自动注入 sign 头。
|
||||
*/
|
||||
interface DayCardApiService {
|
||||
|
||||
/** 1. 设备初始化(唯一不需要 sign 的接口) */
|
||||
@POST("api/jst/init")
|
||||
suspend fun init(@Body body: InitRequest): InitResponse
|
||||
|
||||
/** 2. 查询天卡权益(播放鉴权,返回 thirdCardNo)。无请求体。 */
|
||||
@POST("api/jst/checkBenefits")
|
||||
suspend fun checkBenefits(): CheckBenefitsResponse
|
||||
|
||||
/** 3. 创建天卡订单(deviceId 从 sign 解析,不传设备标识)。无请求体。 */
|
||||
@POST("api/jst/daycard/create")
|
||||
suspend fun createOrder(): CreateOrderResponse
|
||||
|
||||
/** 4. 查询订单状态。status:CREATED / PAYING / PAID / CANCELLED */
|
||||
@GET("api/jst/daycard/status/{orderNo}")
|
||||
suspend fun getOrderStatus(@Path("orderNo") orderNo: String): OrderStatusResponse
|
||||
|
||||
/** 5. 心跳续期(建议每 30 分钟一次)。无请求体。 */
|
||||
@POST("api/jst/heartBeat")
|
||||
suspend fun heartBeat(): HeartbeatResponse
|
||||
|
||||
/** 6. 刷新 sign(用当前 sign 换新 sign,拦截器自动注入当前 sign)。无请求体。 */
|
||||
@POST("api/jst/refreshSign")
|
||||
suspend fun refreshSign(): RefreshSignResponse
|
||||
}
|
||||
347
app/src/main/java/com/pukka/mediaapp/network/DayCardSignUtil.kt
Normal file
@@ -0,0 +1,347 @@
|
||||
package com.pukka.mediaapp.network
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.pukka.mediaapp.config.DayCardConfig
|
||||
import com.pukka.mediaapp.model.daycard.InitRequest
|
||||
import com.pukka.mediaapp.utils.DeviceInfoUtil
|
||||
import com.pukka.mediaapp.utils.FileLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* sign 管理器(对齐生产方案)
|
||||
*
|
||||
* sign 由后端 init 接口下发,App 持久化存储,24h 内复用。
|
||||
* - init:设备初始化,获取 sign(唯一不需要 sign 的接口)
|
||||
* - ensureSign:业务调用前确保 sign 有效,必要时自动 init / refresh
|
||||
* - heartbeat:定时心跳续期
|
||||
* - refresh:sign 即将过期时刷新
|
||||
*/
|
||||
object SignManager {
|
||||
|
||||
private const val TAG = "SignManager"
|
||||
private const val PREFS_NAME = "jst_sign"
|
||||
private const val KEY_SIGN = "sign"
|
||||
private const val KEY_SIGN_TIME = "sign_time"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_ACTIVATION_CODE = "activation_code"
|
||||
private const val KEY_THIRD_CARD_NO = "third_card_no"
|
||||
private const val KEY_BENEFITS_EXPIRE_AT = "benefits_expire_at"
|
||||
|
||||
private lateinit var appContext: Context
|
||||
|
||||
private val _sign = MutableStateFlow("")
|
||||
val sign: StateFlow<String> = _sign.asStateFlow()
|
||||
|
||||
/** Application 启动时调用,加载持久化的 sign */
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
_sign.value = loadSign()
|
||||
FileLogger.init(appContext)
|
||||
FileLogger.log(TAG, "init: hasSign=${_sign.value.isNotEmpty()} baseUrl=${DayCardConfig.BASE_URL}")
|
||||
Log.d(TAG, "init: hasSign=${_sign.value.isNotEmpty()}")
|
||||
}
|
||||
|
||||
fun hasSign(): Boolean = _sign.value.isNotEmpty()
|
||||
|
||||
fun getCurrentSign(): String = _sign.value
|
||||
|
||||
/** 是否已激活(有持久化的 activationCode) */
|
||||
fun isActivated(): Boolean = loadActivationCode().isNotEmpty()
|
||||
|
||||
/** 获取已保存的激活码 */
|
||||
fun getActivationCode(): String = loadActivationCode()
|
||||
|
||||
/** 业务调用前确保 sign 有效;为空或即将过期则自动 init/refresh */
|
||||
suspend fun ensureSign(): String {
|
||||
val current = _sign.value
|
||||
if (current.isNotEmpty() && !needRefresh()) return current
|
||||
return if (current.isNotEmpty()) {
|
||||
try { refresh() } catch (e: Exception) {
|
||||
FileLogger.error(TAG, "ensureSign refresh 失败,回退 init", e)
|
||||
doInit()
|
||||
}
|
||||
} else {
|
||||
doInit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活设备:保存激活码并调 init 获取 sign
|
||||
* 由激活码输入框调用。init 失败会清除激活码,避免下次启动误判为已激活。
|
||||
*/
|
||||
suspend fun activate(activationCode: String): String {
|
||||
require(activationCode.isNotEmpty()) { "激活码不能为空" }
|
||||
FileLogger.log(TAG, "activate: 开始激活 activationCode=$activationCode")
|
||||
saveActivationCode(activationCode)
|
||||
return try {
|
||||
doInit()
|
||||
} catch (e: Exception) {
|
||||
FileLogger.error(TAG, "activate: 激活失败,清除激活码", e)
|
||||
// 激活失败,清除激活码,下次启动重新弹激活框
|
||||
clearActivationCode()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 调用 init 接口获取 sign(无需 sign 头),使用已保存的 activationCode */
|
||||
suspend fun doInit(): String {
|
||||
FileLogger.log(TAG, "doInit: 开始,目标地址=${DayCardConfig.BASE_URL}${DayCardConfig.Path.INIT}")
|
||||
val activationCode = loadActivationCode()
|
||||
if (activationCode.isEmpty()) {
|
||||
FileLogger.error(TAG, "doInit: 设备未激活,请先输入激活码")
|
||||
throw RuntimeException("设备未激活,请先输入激活码")
|
||||
}
|
||||
val identity = DeviceInfoUtil.getDeviceIdentity(appContext)
|
||||
val req = InitRequest(
|
||||
activationCode = activationCode,
|
||||
mac = identity.mac,
|
||||
wifiMac = identity.wifiMac,
|
||||
deviceSn = identity.deviceSn,
|
||||
deviceUid = identity.deviceUid,
|
||||
androidId = identity.androidId,
|
||||
manufacturer = identity.manufacturer,
|
||||
model = identity.model,
|
||||
brand = identity.brand
|
||||
)
|
||||
FileLogger.log(TAG, "doInit: 请求体 activationCode=${req.activationCode} mac=${req.mac} wifiMac=${req.wifiMac} sn=${req.deviceSn} uid=${req.deviceUid} androidId=${req.androidId} brand=${req.brand} model=${req.model}")
|
||||
Log.d(TAG, "doInit: activationCode=${req.activationCode} uid=${req.deviceUid}")
|
||||
val resp = try {
|
||||
DayCardRetrofitClient.apiService.init(req)
|
||||
} catch (e: Exception) {
|
||||
FileLogger.error(TAG, "doInit: 网络请求异常 ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
if (resp.isSuccess && resp.sign.isNotEmpty()) {
|
||||
val now = System.currentTimeMillis()
|
||||
saveSign(resp.sign, now)
|
||||
resp.data?.device?.deviceId?.let { saveDeviceId(it) }
|
||||
_sign.value = resp.sign
|
||||
FileLogger.log(TAG, "doInit: 成功 deviceId=${resp.data?.device?.deviceId} code=${resp.code}")
|
||||
Log.d(TAG, "doInit success: deviceId=${resp.data?.device?.deviceId}")
|
||||
return resp.sign
|
||||
}
|
||||
FileLogger.error(TAG, "doInit: 业务失败 code=${resp.code} msg=${resp.msg}")
|
||||
throw RuntimeException(resp.msg.ifEmpty { "设备初始化失败" })
|
||||
}
|
||||
|
||||
/** 刷新 sign(用旧 sign 换新 sign) */
|
||||
suspend fun refresh(): String {
|
||||
val oldSign = _sign.value
|
||||
if (oldSign.isEmpty()) return doInit()
|
||||
// 当前 sign 由拦截器自动注入 header
|
||||
val resp = try {
|
||||
DayCardRetrofitClient.apiService.refreshSign()
|
||||
} catch (e: Exception) {
|
||||
FileLogger.error(TAG, "refresh: 网络异常,回退 init", e)
|
||||
return doInit()
|
||||
}
|
||||
if (resp.isSuccess) {
|
||||
val newSign = resp.data?.sign ?: ""
|
||||
if (newSign.isNotEmpty()) {
|
||||
saveSign(newSign, System.currentTimeMillis())
|
||||
_sign.value = newSign
|
||||
FileLogger.log(TAG, "refresh success")
|
||||
Log.d(TAG, "refresh success")
|
||||
return newSign
|
||||
}
|
||||
}
|
||||
// 刷新失败,回退到 init
|
||||
FileLogger.log(TAG, "refresh failed: ${resp.msg}, fallback to init")
|
||||
Log.w(TAG, "refresh failed: ${resp.msg}, fallback to init")
|
||||
return doInit()
|
||||
}
|
||||
|
||||
/** 心跳续期(失败不阻断业务) */
|
||||
suspend fun heartbeat() {
|
||||
if (_sign.value.isEmpty()) return
|
||||
try {
|
||||
DayCardRetrofitClient.apiService.heartBeat()
|
||||
FileLogger.log(TAG, "heartbeat ok")
|
||||
Log.d(TAG, "heartbeat ok")
|
||||
} catch (e: Exception) {
|
||||
FileLogger.error(TAG, "heartbeat failed", e)
|
||||
Log.e(TAG, "heartbeat failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除本地 sign(保留激活码,仅清 sign,用于 sign 失效重新 init) */
|
||||
fun clearSign() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_SIGN)
|
||||
.remove(KEY_SIGN_TIME)
|
||||
.apply()
|
||||
_sign.value = ""
|
||||
}
|
||||
|
||||
/** 完全重置(清除激活码 + sign,用于切换门店/重新激活) */
|
||||
fun clear() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
_sign.value = ""
|
||||
}
|
||||
|
||||
/** 缓存天卡权益(thirdCardNo + 过期时间),有效期内复用,避免每次播放都鉴权 */
|
||||
fun cacheBenefits(thirdCardNo: String, expireAt: String) {
|
||||
val expireMs = parseExpireAtToMillis(expireAt)
|
||||
val finalMs = if (expireMs > 0) expireMs else System.currentTimeMillis() + DayCardConfig.SIGN_TTL
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_THIRD_CARD_NO, thirdCardNo)
|
||||
.putLong(KEY_BENEFITS_EXPIRE_AT, finalMs)
|
||||
.apply()
|
||||
FileLogger.log(TAG, "cacheBenefits: thirdCardNo=$thirdCardNo expireAt=$expireAt -> $finalMs")
|
||||
}
|
||||
|
||||
/** 获取缓存的 thirdCardNo(过期返回空串) */
|
||||
fun getCachedThirdCardNo(): String {
|
||||
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val thirdCardNo = prefs.getString(KEY_THIRD_CARD_NO, "") ?: ""
|
||||
if (thirdCardNo.isEmpty()) return ""
|
||||
val expireMs = prefs.getLong(KEY_BENEFITS_EXPIRE_AT, 0L)
|
||||
if (expireMs > 0 && System.currentTimeMillis() >= expireMs) {
|
||||
FileLogger.log(TAG, "getCachedThirdCardNo: 权益已过期")
|
||||
return ""
|
||||
}
|
||||
return thirdCardNo
|
||||
}
|
||||
|
||||
/** 清除权益缓存 */
|
||||
fun clearBenefits() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_THIRD_CARD_NO)
|
||||
.remove(KEY_BENEFITS_EXPIRE_AT)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun parseExpireAtToMillis(expireAt: String): Long {
|
||||
if (expireAt.isEmpty()) return 0L
|
||||
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",
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)
|
||||
for (pattern in formats) {
|
||||
try {
|
||||
val sdf = SimpleDateFormat(pattern, Locale.getDefault())
|
||||
val date = sdf.parse(expireAt)
|
||||
if (date != null) return date.time
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return 0L
|
||||
}
|
||||
|
||||
/** 缓存的 deviceId(init 返回) */
|
||||
fun getCachedDeviceId(): Long? {
|
||||
val id = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_DEVICE_ID, -1L)
|
||||
return if (id > 0) id else null
|
||||
}
|
||||
|
||||
private fun needRefresh(): Boolean {
|
||||
val signTime = loadSignTime()
|
||||
return signTime <= 0 || System.currentTimeMillis() - signTime >= DayCardConfig.SIGN_REFRESH_THRESHOLD
|
||||
}
|
||||
|
||||
private fun loadSign(): String =
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_SIGN, "") ?: ""
|
||||
|
||||
private fun loadSignTime(): Long =
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_SIGN_TIME, 0L)
|
||||
|
||||
private fun saveSign(sign: String, time: Long) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_SIGN, sign)
|
||||
.putLong(KEY_SIGN_TIME, time)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun saveDeviceId(id: Long) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
||||
.putLong(KEY_DEVICE_ID, id)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun loadActivationCode(): String =
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_ACTIVATION_CODE, "") ?: ""
|
||||
|
||||
private fun saveActivationCode(code: String) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_ACTIVATION_CODE, code)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun clearActivationCode() {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_ACTIVATION_CODE)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sign 鉴权拦截器:为非 init 请求注入单个 sign 头
|
||||
*/
|
||||
object SignInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
|
||||
val original = chain.request()
|
||||
val path = original.url().encodedPath()
|
||||
|
||||
// init 接口不需要 sign
|
||||
if (path.contains(DayCardConfig.Path.INIT)) {
|
||||
return chain.proceed(original)
|
||||
}
|
||||
|
||||
val sign = SignManager.getCurrentSign()
|
||||
val builder = original.newBuilder()
|
||||
if (sign.isNotEmpty()) {
|
||||
builder.addHeader(DayCardConfig.SIGN_HEADER, sign)
|
||||
}
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 天卡 API Retrofit 客户端(单 sign 头鉴权)
|
||||
*/
|
||||
object DayCardRetrofitClient {
|
||||
|
||||
private val okHttpClient: OkHttpClient by lazy {
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(SignInterceptor)
|
||||
.addInterceptor(FileLoggingInterceptor)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val retrofit: Retrofit by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(DayCardConfig.BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
val apiService: DayCardApiService by lazy {
|
||||
retrofit.create(DayCardApiService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.pukka.mediaapp.network
|
||||
|
||||
import com.pukka.mediaapp.utils.FileLogger
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* HTTP 文件日志拦截器
|
||||
*
|
||||
* 记录请求 URL、响应码及网络异常到日志文件,
|
||||
* 用于诊断「请求无法到达后端」类问题。
|
||||
*/
|
||||
object FileLoggingInterceptor : Interceptor {
|
||||
|
||||
private const val TAG = "Http"
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val url = request.url().toString()
|
||||
val method = request.method()
|
||||
|
||||
FileLogger.log(TAG, ">> $method $url")
|
||||
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
FileLogger.log(
|
||||
TAG,
|
||||
"<< ${response.code()} ${response.message()} $url (耗时=${response.receivedResponseAtMillis() - response.sentRequestAtMillis()}ms)"
|
||||
)
|
||||
response
|
||||
} catch (e: IOException) {
|
||||
// 网络层异常:连接超时、DNS 解析失败、连接拒绝等
|
||||
FileLogger.error(
|
||||
TAG,
|
||||
"!! 请求失败 $url: ${e.javaClass.simpleName}: ${e.message}",
|
||||
e
|
||||
)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
122
app/src/main/java/com/pukka/mediaapp/network/MediaRepository.kt
Normal file
@@ -0,0 +1,122 @@
|
||||
package com.pukka.mediaapp.network
|
||||
|
||||
import com.pukka.mediaapp.config.ApiConfig
|
||||
import com.pukka.mediaapp.model.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 媒资数据仓库 - 封装所有API调用
|
||||
*/
|
||||
class MediaRepository {
|
||||
|
||||
private val api = RetrofitClient.apiService
|
||||
|
||||
/** 获取首页导航 */
|
||||
suspend fun getLayoutNav(): Result<LayoutNavResponse> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = SignUtil.buildParams("stbid" to ApiConfig.STB_ID)
|
||||
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.LAYOUT_NAV
|
||||
val response = api.getLayoutNav(url, params)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取二级页面布局 */
|
||||
suspend fun getLayout(navid: String): Result<LayoutResponse> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = SignUtil.buildParams("navid" to navid)
|
||||
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.LAYOUT
|
||||
val response = api.getLayout(url, params)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取内容列表 */
|
||||
suspend fun getContentList(
|
||||
catalogid: String,
|
||||
pageindex: Int = 1,
|
||||
pagesize: Int = 20
|
||||
): Result<ContentListResponse> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = SignUtil.buildParams(
|
||||
"catalogid" to catalogid,
|
||||
"pageindex" to pageindex.toString(),
|
||||
"pagesize" to pagesize.toString()
|
||||
)
|
||||
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.CONTENT_LIST
|
||||
val response = api.getContentList(url, params)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取内容详情 */
|
||||
suspend fun getContentDetail(contentcode: String): Result<ContentListResponse> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = SignUtil.buildParams(
|
||||
"contentcode" to contentcode,
|
||||
"need4k" to "2",
|
||||
"needDrm" to "2"
|
||||
)
|
||||
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.CONTENT_LIST
|
||||
val response = api.getContentList(url, params)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索 */
|
||||
suspend fun search(
|
||||
keyword: String,
|
||||
pageindex: Int = 1,
|
||||
pagesize: Int = 20
|
||||
): Result<SearchResultResponse> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = SignUtil.buildParams(
|
||||
"searchvalue" to keyword,
|
||||
"pageindex" to pageindex.toString(),
|
||||
"pagesize" to pagesize.toString(),
|
||||
"firstproject" to ""
|
||||
)
|
||||
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.SEARCH
|
||||
val response = api.search(url, params)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取栏目标签 */
|
||||
suspend fun getTagsList(parentid: String): Result<TagsListResponse> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val params = SignUtil.buildParams(
|
||||
"type" to "1",
|
||||
"parentid" to parentid
|
||||
)
|
||||
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.TAGS_LIST
|
||||
val response = api.getTagsList(url, params)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: MediaRepository? = null
|
||||
|
||||
fun getInstance(): MediaRepository {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: MediaRepository().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.pukka.mediaapp.network
|
||||
|
||||
import com.pukka.mediaapp.config.ApiConfig
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object RetrofitClient {
|
||||
|
||||
private val okHttpClient: OkHttpClient by lazy {
|
||||
val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(FileLoggingInterceptor)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val retrofit: Retrofit by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(ApiConfig.BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
val apiService: ApiService by lazy {
|
||||
retrofit.create(ApiService::class.java)
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/com/pukka/mediaapp/network/SignUtil.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.pukka.mediaapp.network
|
||||
|
||||
import com.pukka.mediaapp.config.ApiConfig
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 媒资接口签名工具
|
||||
*
|
||||
* 签名算法: sign = MD5(sptoken + timeshift + portalcode + KEY).toUpperCase()
|
||||
*/
|
||||
object SignUtil {
|
||||
|
||||
/**
|
||||
* 生成公共参数(包含签名)
|
||||
* @return Map 包含 timeshift, sign, portalcode, sptoken, channelid
|
||||
*/
|
||||
fun buildCommonParams(): MutableMap<String, String> {
|
||||
val timeshift = System.currentTimeMillis().toString()
|
||||
val sign = md5Upper("${ApiConfig.SP_TOKEN}${timeshift}${ApiConfig.PORTAL_CODE}${ApiConfig.SIGN_KEY}")
|
||||
return mutableMapOf(
|
||||
"timeshift" to timeshift,
|
||||
"sign" to sign,
|
||||
"portalcode" to ApiConfig.PORTAL_CODE,
|
||||
"sptoken" to ApiConfig.SP_TOKEN,
|
||||
"channelid" to ApiConfig.CHANNEL_ID
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整请求参数(公共参数 + 业务参数)
|
||||
*/
|
||||
fun buildParams(vararg pairs: Pair<String, String>): Map<String, String> {
|
||||
val params = buildCommonParams()
|
||||
pairs.forEach { (key, value) ->
|
||||
params[key] = value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* MD5 大写
|
||||
*/
|
||||
private fun md5Upper(input: String): String {
|
||||
val md = MessageDigest.getInstance("MD5")
|
||||
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02X".format(it) }
|
||||
}
|
||||
}
|
||||
282
app/src/main/java/com/pukka/mediaapp/sdk/JetsenxSdkUtil.kt
Normal file
@@ -0,0 +1,282 @@
|
||||
package com.pukka.mediaapp.sdk
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.jetsenxsdk.api.JetsenxSdk
|
||||
import com.jetsenxsdk.bean.VideoInfo
|
||||
import com.jetsenxsdk.callback.JetsenxInitCallback
|
||||
import com.jetsenxsdk.callback.JetsenxPricesCallback
|
||||
import com.jetsenxsdk.callback.JetsenxUrlCallback
|
||||
import com.pukka.mediaapp.utils.FileLogger
|
||||
|
||||
/**
|
||||
* JetsenxSdk Kotlin封装 - 从生产环境JetsenxSdkUtil.java移植
|
||||
*
|
||||
* 视频地址获取流程:
|
||||
* 1. init() 初始化SDK
|
||||
* 2. getAllVideoUrl() → getAllAssetPrices(获取productCode) → getVideoUrls/getFreeVideoUrls
|
||||
* 3. 根据parentContentType=="8"判断免费/付费
|
||||
*/
|
||||
object JetsenxSdkUtil {
|
||||
|
||||
private const val TAG = "JetsenxSdkUtil"
|
||||
|
||||
// 渠道配置 (debug测试环境 - 与生产环境ChannelManger.initChannel()一致)
|
||||
private const val AGCODE = "1100101010200492358120169"
|
||||
private const val TENANT = "huashitv"
|
||||
private const val ACCESSER = "JSTASJD"
|
||||
private const val SECRET = "kMeWsBu0QsTXpnft"
|
||||
private const val SERVICE_COMBO_CODE = "1100121010200494755850172"
|
||||
|
||||
// MAC保底值
|
||||
private const val FALLBACK_MAC = "CC:8C:DA:C6:E9:9B"
|
||||
|
||||
private var productCodeSp: String? = null
|
||||
private var isInitialized = false
|
||||
|
||||
/** SDK初始化回调 */
|
||||
interface InitCallback {
|
||||
fun onSuccess(memberCode: String)
|
||||
fun onFail(message: String)
|
||||
}
|
||||
|
||||
/** 视频地址回调 */
|
||||
interface VideoUrlCallback {
|
||||
fun onSuccess(videoUrl1080: String, videoUrl720: String, isFree: Boolean)
|
||||
fun onError(code: String, message: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化SDK
|
||||
* @param context 上下文
|
||||
* @param uid 用户标识(thirdCardNo,从天卡权益接口获取)
|
||||
* @param callback 初始化回调
|
||||
*/
|
||||
fun init(context: Context, uid: String = "test_user", callback: InitCallback? = null) {
|
||||
val mac = getMacAddress(context)
|
||||
val finalMac = if (TextUtils.isEmpty(mac)) FALLBACK_MAC else mac
|
||||
val finalUid = if (TextUtils.isEmpty(uid)) finalMac else uid
|
||||
// 判断uid是否为MAC地址格式
|
||||
val utype = if (finalUid.matches(Regex("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"))) "3" else "2"
|
||||
|
||||
Log.d(TAG, "init: agcode=$AGCODE mac=$finalMac uid=$finalUid tenant=$TENANT utype=$utype")
|
||||
FileLogger.log(TAG, "init: agcode=$AGCODE mac=$finalMac uid=$finalUid tenant=$TENANT utype=$utype")
|
||||
|
||||
try {
|
||||
JetsenxSdk.init(
|
||||
context, AGCODE, finalMac, finalUid,
|
||||
TENANT, SECRET, ACCESSER, utype, "",
|
||||
SERVICE_COMBO_CODE,
|
||||
object : JetsenxInitCallback {
|
||||
override fun onInitSuccess(memberCode: String, p1: Boolean) {
|
||||
Log.d(TAG, "onInitSuccess: memberCode=$memberCode")
|
||||
FileLogger.log(TAG, "SDK init成功: memberCode=$memberCode")
|
||||
isInitialized = true
|
||||
callback?.onSuccess(memberCode)
|
||||
}
|
||||
|
||||
override fun onFail(code: String, msg: String) {
|
||||
Log.e(TAG, "onInitFail: code=$code msg=$msg")
|
||||
FileLogger.error(TAG, "SDK init失败: code=$code msg=$msg")
|
||||
isInitialized = false
|
||||
callback?.onFail(if (msg.isNotEmpty()) msg else "SDK初始化失败")
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "init exception", e)
|
||||
callback?.onFail("SDK初始化异常: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频播放地址
|
||||
* @param parentContentType 父内容类型 ("8"=免费, 其他=付费)
|
||||
* @param programCode 节目编码
|
||||
* @param callback 回调
|
||||
*/
|
||||
fun getAllVideoUrl(
|
||||
parentContentType: String,
|
||||
programCode: String,
|
||||
callback: VideoUrlCallback
|
||||
) {
|
||||
Log.d(TAG, "getAllVideoUrl: parentContentType=$parentContentType programCode=$programCode")
|
||||
FileLogger.log(TAG, "getAllVideoUrl: parentContentType=$parentContentType programCode=$programCode")
|
||||
|
||||
if (TextUtils.isEmpty(programCode)) {
|
||||
callback.onError("-1", "programCode is empty")
|
||||
return
|
||||
}
|
||||
|
||||
// 跳过未来播控鉴权(当前放通),直接获取视频地址
|
||||
getAllVideoUrlNext(parentContentType, programCode, callback)
|
||||
}
|
||||
|
||||
private fun getAllVideoUrlNext(
|
||||
parentContentType: String,
|
||||
programCode: String,
|
||||
callback: VideoUrlCallback
|
||||
) {
|
||||
val cachedProductCode = productCodeSp
|
||||
if (!cachedProductCode.isNullOrEmpty()) {
|
||||
getAllVideoUrl2(parentContentType, cachedProductCode, programCode, callback)
|
||||
} else {
|
||||
// 获取productCode
|
||||
JetsenxSdk.getAllAssetPrices(ACCESSER, SECRET, object : JetsenxPricesCallback {
|
||||
override fun onAllAssetPrices(list: MutableList<Map<String, Any>>?) {
|
||||
FileLogger.log(TAG, "getAllAssetPrices返回: count=${list?.size}")
|
||||
if (list != null && list.isNotEmpty()) {
|
||||
// 打印完整资费列表结构
|
||||
list.forEachIndexed { index, map ->
|
||||
FileLogger.log(TAG, " assetPrice[$index]: $map")
|
||||
}
|
||||
val productCode = list[0]["packageCode"]?.toString() ?: ""
|
||||
Log.d(TAG, "onAllAssetPrices: productCode=$productCode")
|
||||
FileLogger.log(TAG, "使用productCode=$productCode")
|
||||
productCodeSp = productCode
|
||||
getAllVideoUrl2(parentContentType, productCode, programCode, callback)
|
||||
} else {
|
||||
callback.onError("-2", "productCode is NULL")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFail(code: String, msg: String) {
|
||||
Log.e(TAG, "getAllAssetPrices onFail: code=$code msg=$msg")
|
||||
FileLogger.error(TAG, "getAllAssetPrices失败: code=$code msg=$msg")
|
||||
callback.onError(code, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAllVideoUrl2(
|
||||
parentContentType: String,
|
||||
productCode: String,
|
||||
programCode: String,
|
||||
callback: VideoUrlCallback
|
||||
) {
|
||||
Log.d(TAG, "getAllVideoUrl2: parentContentType=$parentContentType productCode=$productCode")
|
||||
FileLogger.log(TAG, "getAllVideoUrl2: parentContentType=$parentContentType productCode=$productCode 付费=${parentContentType != "8"}")
|
||||
if (TextUtils.isEmpty(parentContentType)) {
|
||||
callback.onError("-1", "parentContentType is NULL")
|
||||
return
|
||||
}
|
||||
if (parentContentType != "8") {
|
||||
// 付费内容
|
||||
getVideoUrls2(productCode, programCode, callback)
|
||||
} else {
|
||||
// 免费内容
|
||||
getFreeVideoUrls2(programCode, callback)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取付费内容播放地址 */
|
||||
private fun getVideoUrls2(
|
||||
productCode: String,
|
||||
programCode: String,
|
||||
callback: VideoUrlCallback
|
||||
) {
|
||||
Log.d(TAG, "getVideoUrls2: productCode=$productCode programCode=$programCode")
|
||||
JetsenxSdk.getVideoUrls(productCode, programCode, object : JetsenxUrlCallback {
|
||||
override fun onVideoUrls(videoInfos: Array<VideoInfo>?) {
|
||||
Log.d(TAG, "getVideoUrls onVideoUrls: count=${videoInfos?.size}")
|
||||
FileLogger.log(TAG, "getVideoUrls返回: count=${videoInfos?.size}")
|
||||
if (videoInfos != null) {
|
||||
videoInfos.forEachIndexed { index, info ->
|
||||
FileLogger.log(TAG, " VideoInfo[$index]: format=${info.format} videoUrl=${info.videoUrl}")
|
||||
}
|
||||
}
|
||||
if (videoInfos != null && videoInfos.isNotEmpty()) {
|
||||
val (url1080, url720) = extractUrls(videoInfos)
|
||||
if (url1080.isNotEmpty() || url720.isNotEmpty()) {
|
||||
callback.onSuccess(url1080, url720, false)
|
||||
} else {
|
||||
callback.onError("411", "未返回播放地址")
|
||||
}
|
||||
} else {
|
||||
callback.onError("411", "未返回播放地址")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFail(code: String, msg: String) {
|
||||
Log.e(TAG, "getVideoUrls onFail: code=$code msg=$msg")
|
||||
FileLogger.error(TAG, "getVideoUrls(付费)失败: code=$code msg=$msg")
|
||||
// "未订购"时,尝试用免费接口获取(某些内容可能实际可免费播放)
|
||||
if (msg.contains("未订购") || code == "-110") {
|
||||
Log.d(TAG, "未订购,尝试getFreeVideoUrls回退")
|
||||
getFreeVideoUrls2(programCode, callback)
|
||||
} else {
|
||||
callback.onError(code, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取免费内容播放地址 */
|
||||
private fun getFreeVideoUrls2(
|
||||
programCode: String,
|
||||
callback: VideoUrlCallback
|
||||
) {
|
||||
Log.d(TAG, "getFreeVideoUrls2: programCode=$programCode")
|
||||
JetsenxSdk.getFreeVideoUrls(programCode, object : JetsenxUrlCallback {
|
||||
override fun onVideoUrls(videoInfos: Array<VideoInfo>?) {
|
||||
Log.d(TAG, "getFreeVideoUrls onVideoUrls: count=${videoInfos?.size}")
|
||||
FileLogger.log(TAG, "getFreeVideoUrls返回: count=${videoInfos?.size}")
|
||||
if (videoInfos != null) {
|
||||
videoInfos.forEachIndexed { index, info ->
|
||||
FileLogger.log(TAG, " VideoInfo[$index]: format=${info.format} videoUrl=${info.videoUrl}")
|
||||
}
|
||||
}
|
||||
if (videoInfos != null && videoInfos.isNotEmpty()) {
|
||||
val (url1080, url720) = extractUrls(videoInfos)
|
||||
if (url1080.isNotEmpty() || url720.isNotEmpty()) {
|
||||
callback.onSuccess(url1080, url720, true)
|
||||
} else {
|
||||
callback.onError("410", "未返回播放地址")
|
||||
}
|
||||
} else {
|
||||
callback.onError("410", "未返回播放地址")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFail(code: String, msg: String) {
|
||||
Log.e(TAG, "getFreeVideoUrls onFail: code=$code msg=$msg")
|
||||
FileLogger.error(TAG, "getFreeVideoUrls(免费)失败: code=$code msg=$msg")
|
||||
callback.onError(code, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 从VideoInfo数组中提取1080p和720p地址 */
|
||||
private fun extractUrls(videoInfos: Array<VideoInfo>): Pair<String, String> {
|
||||
var url1080 = ""
|
||||
var url720 = ""
|
||||
for (info in videoInfos) {
|
||||
val format = info.format ?: ""
|
||||
if (format.contains("720") && url720.isEmpty()) {
|
||||
url720 = info.videoUrl ?: ""
|
||||
} else if (format.contains("1080") && url1080.isEmpty()) {
|
||||
url1080 = info.videoUrl ?: ""
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "extractUrls: 1080=$url1080 720=$url720")
|
||||
FileLogger.log(TAG, "extractUrls: 1080=$url1080 720=$url720")
|
||||
return Pair(url1080, url720)
|
||||
}
|
||||
|
||||
/** 获取设备MAC地址 */
|
||||
private fun getMacAddress(context: Context): String {
|
||||
return try {
|
||||
val wifiManager = context.applicationContext
|
||||
.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
|
||||
val wifiInfo = wifiManager.connectionInfo
|
||||
val mac = wifiInfo.macAddress
|
||||
if (mac.isNullOrEmpty() || mac == "02:00:00:00:00:00") "" else mac
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun isReady(): Boolean = isInitialized
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.pukka.mediaapp.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.pukka.mediaapp.databinding.ActivityActivationBinding
|
||||
import com.pukka.mediaapp.network.SignManager
|
||||
import com.pukka.mediaapp.utils.FileLogger
|
||||
import com.pukka.mediaapp.widget.DeviceNotActivatedView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 激活码输入页(完整移植生产项目 DeviceNotActivatedView)
|
||||
*
|
||||
* - 三栏布局:设备信息 | 码框预览 | 数字键盘
|
||||
* - 输满激活码自动提交,或手动提交
|
||||
* - 激活成功跳转 HomeActivity
|
||||
*/
|
||||
class ActivationActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityActivationBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 已激活则直接进首页
|
||||
if (SignManager.isActivated() && SignManager.hasSign()) {
|
||||
startActivity(Intent(this, HomeActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
binding = ActivityActivationBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.activationView.setOnActivateListener(object : DeviceNotActivatedView.OnActivateListener {
|
||||
override fun onSubmit(activateCode: String, roomNo: String) {
|
||||
submitActivation(activateCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun submitActivation(code: String) {
|
||||
FileLogger.log("ActivationActivity", "提交激活码: $code")
|
||||
binding.activationView.showActivateMessage("正在激活...")
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { SignManager.activate(code) }
|
||||
FileLogger.log("ActivationActivity", "激活成功")
|
||||
binding.activationView.showActivateMessage("激活成功")
|
||||
startActivity(Intent(this@ActivationActivity, HomeActivity::class.java))
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
FileLogger.error("ActivationActivity", "激活失败: ${e.message}", e)
|
||||
binding.activationView.showActivateErrorMessage("激活失败:${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
544
app/src/main/java/com/pukka/mediaapp/ui/DetailActivity.kt
Normal file
@@ -0,0 +1,544 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
139
app/src/main/java/com/pukka/mediaapp/ui/HomeActivity.kt
Normal file
@@ -0,0 +1,139 @@
|
||||
package com.pukka.mediaapp.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
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 androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.pukka.mediaapp.databinding.ActivityHomeBinding
|
||||
import com.pukka.mediaapp.network.SignManager
|
||||
import com.pukka.mediaapp.ui.adapter.NavAdapter
|
||||
import com.pukka.mediaapp.ui.adapter.SectionAdapter
|
||||
import com.pukka.mediaapp.viewmodel.HomeViewModel
|
||||
import com.pukka.mediaapp.widget.TopView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 首页:顶部导航栏 + 楼层内容区 + 搜索入口。
|
||||
*
|
||||
* 启动流程:
|
||||
* 1. 检查设备是否已激活(有持久化的 activationCode)
|
||||
* 2. 未激活 -> 弹激活码输入框 -> 调 SignManager.activate 完成 init
|
||||
* 3. 已激活但无 sign -> 后台自动 doInit 获取 sign
|
||||
* 4. 加载首页导航
|
||||
*/
|
||||
class HomeActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityHomeBinding
|
||||
private val viewModel: HomeViewModel by viewModels()
|
||||
|
||||
private lateinit var navAdapter: NavAdapter
|
||||
private lateinit var sectionAdapter: SectionAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityHomeBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
setupViews()
|
||||
observeViewModel()
|
||||
|
||||
// 启动激活/鉴权流程
|
||||
when {
|
||||
!SignManager.isActivated() -> showActivationDialog()
|
||||
SignManager.hasSign() -> viewModel.loadNav()
|
||||
else -> {
|
||||
// 已激活但 sign 丢失,尝试重新 init;失败则重新弹激活框
|
||||
lifecycleScope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { runCatching { SignManager.doInit() }.isSuccess }
|
||||
if (ok) viewModel.loadNav()
|
||||
else showActivationDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转激活码输入页 */
|
||||
private fun showActivationDialog() {
|
||||
startActivity(Intent(this, ActivationActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
/** 执行激活:已移至 ActivationActivity,此处保留兼容 */
|
||||
private fun doActivate(code: String) {
|
||||
startActivity(Intent(this, ActivationActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
navAdapter = NavAdapter { navItem ->
|
||||
viewModel.loadLayout(navItem.navid)
|
||||
}
|
||||
binding.rvNav.apply {
|
||||
layoutManager = LinearLayoutManager(this@HomeActivity, LinearLayoutManager.HORIZONTAL, false)
|
||||
adapter = navAdapter
|
||||
setHasFixedSize(true)
|
||||
}
|
||||
|
||||
sectionAdapter = SectionAdapter { content -> openDetail(content.contentcode, content.name) }
|
||||
binding.rvContent.apply {
|
||||
layoutManager = LinearLayoutManager(this@HomeActivity, LinearLayoutManager.VERTICAL, false)
|
||||
adapter = sectionAdapter
|
||||
setHasFixedSize(true)
|
||||
}
|
||||
|
||||
binding.topView.setOnTopButtonClickListener(object : TopView.OnTopButtonClickListener {
|
||||
override fun onSearchClick() {
|
||||
startActivity(Intent(this@HomeActivity, SearchActivity::class.java))
|
||||
}
|
||||
|
||||
override fun onHistoryClick() {
|
||||
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onOrderClick() {
|
||||
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onHelpClick() {
|
||||
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onServiceClick() {
|
||||
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onSettingClick() {
|
||||
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onCollectClick() {
|
||||
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch { viewModel.navItems.collect { navAdapter.submitList(it, viewModel.selectedNavIndex.value) } }
|
||||
launch { viewModel.sections.collect { sectionAdapter.submitList(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDetail(contentcode: String, name: String) {
|
||||
startActivity(
|
||||
Intent(this, DetailActivity::class.java).apply {
|
||||
putExtra(DetailActivity.EXTRA_CONTENTCODE, contentcode)
|
||||
putExtra(DetailActivity.EXTRA_NAME, name)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
97
app/src/main/java/com/pukka/mediaapp/ui/ListActivity.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.pukka.mediaapp.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.pukka.mediaapp.databinding.ActivityListBinding
|
||||
import com.pukka.mediaapp.ui.adapter.ContentAdapter
|
||||
import com.pukka.mediaapp.ui.adapter.TagAdapter
|
||||
import com.pukka.mediaapp.viewmodel.ListViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 列表页:左侧分类标签 + 右侧内容网格(2 列)+ 顶部标题。
|
||||
*
|
||||
* - 接收 catalogid 与 title 参数。
|
||||
* - 支持分页加载:滚动接近底部时自动加载下一页。
|
||||
* - 点击标签重新从第一页加载(触发筛选刷新)。
|
||||
* - 点击内容项跳转 [DetailActivity],传递 contentcode 与 name。
|
||||
*/
|
||||
class ListActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityListBinding
|
||||
private val viewModel: ListViewModel by viewModels()
|
||||
|
||||
private lateinit var tagAdapter: TagAdapter
|
||||
private lateinit var contentAdapter: ContentAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityListBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val catalogid = intent.getStringExtra(EXTRA_CATALOGID).orEmpty()
|
||||
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
||||
binding.tvTitle.text = title
|
||||
|
||||
setupViews()
|
||||
observeViewModel()
|
||||
|
||||
viewModel.init(catalogid)
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
tagAdapter = TagAdapter { _ -> viewModel.loadContent(reset = true) }
|
||||
binding.rvTags.apply {
|
||||
layoutManager = LinearLayoutManager(this@ListActivity, LinearLayoutManager.VERTICAL, false)
|
||||
adapter = tagAdapter
|
||||
}
|
||||
|
||||
contentAdapter = ContentAdapter { content -> openDetail(content.contentcode, content.name) }
|
||||
binding.rvContent.apply {
|
||||
layoutManager = GridLayoutManager(this@ListActivity, 2)
|
||||
adapter = contentAdapter
|
||||
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||
val lm = rv.layoutManager as? GridLayoutManager ?: return
|
||||
val total = lm.itemCount
|
||||
val lastVisible = lm.findLastVisibleItemPosition()
|
||||
// 接近底部时预加载下一页
|
||||
if (total > 0 && lastVisible >= total - 4) {
|
||||
viewModel.loadContent(reset = false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch { viewModel.tags.collect { tagAdapter.submitList(it) } }
|
||||
launch { viewModel.contentList.collect { contentAdapter.submitList(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDetail(contentcode: String, name: String) {
|
||||
startActivity(
|
||||
Intent(this, DetailActivity::class.java).apply {
|
||||
putExtra(DetailActivity.EXTRA_CONTENTCODE, contentcode)
|
||||
putExtra(DetailActivity.EXTRA_NAME, name)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_CATALOGID = "extra_catalogid"
|
||||
const val EXTRA_TITLE = "extra_title"
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/com/pukka/mediaapp/ui/SearchActivity.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package com.pukka.mediaapp.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.pukka.mediaapp.databinding.ActivitySearchBinding
|
||||
import com.pukka.mediaapp.ui.adapter.SearchAdapter
|
||||
import com.pukka.mediaapp.viewmodel.SearchViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 搜索页:搜索输入框 + 搜索按钮 + 结果列表。
|
||||
*
|
||||
* - 点击搜索按钮或键盘搜索动作触发搜索。
|
||||
* - 点击结果项跳转 [DetailActivity],传递 contentcode 与 name。
|
||||
*/
|
||||
class SearchActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivitySearchBinding
|
||||
private val viewModel: SearchViewModel by viewModels()
|
||||
|
||||
private lateinit var searchAdapter: SearchAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivitySearchBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
searchAdapter = SearchAdapter { content -> openDetail(content.contentcode, content.name) }
|
||||
binding.rvResults.apply {
|
||||
layoutManager = LinearLayoutManager(this@SearchActivity)
|
||||
adapter = searchAdapter
|
||||
}
|
||||
|
||||
binding.btnSearch.setOnClickListener { doSearch() }
|
||||
binding.etSearch.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
|
||||
doSearch()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.results.collect { searchAdapter.submitList(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doSearch() {
|
||||
val keyword = binding.etSearch.text?.toString().orEmpty()
|
||||
viewModel.search(keyword)
|
||||
}
|
||||
|
||||
private fun openDetail(contentcode: String, name: String) {
|
||||
startActivity(
|
||||
Intent(this, DetailActivity::class.java).apply {
|
||||
putExtra(DetailActivity.EXTRA_CONTENTCODE, contentcode)
|
||||
putExtra(DetailActivity.EXTRA_NAME, name)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
169
app/src/main/java/com/pukka/mediaapp/ui/VideoPlayerActivity.kt
Normal file
@@ -0,0 +1,169 @@
|
||||
package com.pukka.mediaapp.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import com.pukka.mediaapp.databinding.ActivityPlayerBinding
|
||||
|
||||
/**
|
||||
* 视频播放页面 - 使用ExoPlayer播放视频
|
||||
*
|
||||
* 接收Intent: EXTRA_VIDEO_URL (视频地址), EXTRA_TITLE (标题)
|
||||
*/
|
||||
class VideoPlayerActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityPlayerBinding
|
||||
private var player: ExoPlayer? = null
|
||||
private var hasError = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 全屏沉浸式
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
|
||||
|
||||
binding = ActivityPlayerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val videoUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty()
|
||||
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
||||
|
||||
Log.d(TAG, "onCreate: videoUrl=$videoUrl title=$title")
|
||||
|
||||
binding.btnBack.setOnClickListener { finish() }
|
||||
|
||||
if (videoUrl.isEmpty()) {
|
||||
showError("视频地址为空")
|
||||
return
|
||||
}
|
||||
|
||||
setupPlayer(videoUrl)
|
||||
}
|
||||
|
||||
private fun setupPlayer(videoUrl: String) {
|
||||
showLoading(true)
|
||||
val userAgent = Util.getUserAgent(this, "MediaApp")
|
||||
|
||||
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(userAgent)
|
||||
.setConnectTimeoutMs(15000)
|
||||
.setReadTimeoutMs(30000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val dataSourceFactory = DefaultDataSource.Factory(this, httpDataSourceFactory)
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||
.build()
|
||||
|
||||
binding.playerView.player = player
|
||||
binding.playerView.useController = true
|
||||
binding.playerView.controllerAutoShow = true
|
||||
binding.playerView.controllerHideOnTouch = true
|
||||
binding.playerView.controllerShowTimeoutMs = 5000
|
||||
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(videoUrl))
|
||||
player?.setMediaItem(mediaItem)
|
||||
player?.prepare()
|
||||
player?.playWhenReady = true
|
||||
player?.volume = 1f
|
||||
|
||||
player?.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
when (state) {
|
||||
Player.STATE_READY -> {
|
||||
Log.d(TAG, "STATE_READY")
|
||||
showLoading(false)
|
||||
}
|
||||
Player.STATE_BUFFERING -> {
|
||||
Log.d(TAG, "STATE_BUFFERING")
|
||||
showLoading(true)
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
Log.d(TAG, "STATE_ENDED")
|
||||
showLoading(false)
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
Log.d(TAG, "STATE_IDLE")
|
||||
showLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: com.google.android.exoplayer2.PlaybackException) {
|
||||
Log.e(TAG, "onPlayerError", error)
|
||||
showLoading(false)
|
||||
showError("播放错误: ${error.message}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun showLoading(show: Boolean) {
|
||||
binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
hasError = true
|
||||
showLoading(false)
|
||||
binding.tvError.text = message
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
binding.playerView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!hasError) {
|
||||
player?.playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
player?.playWhenReady = false
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
player?.stop()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
player?.release()
|
||||
player = null
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VideoPlayerActivity"
|
||||
const val EXTRA_VIDEO_URL = "extra_video_url"
|
||||
const val EXTRA_TITLE = "extra_title"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.pukka.mediaapp.ui.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* 适配器公共工具:统一管理主题色、焦点高亮与选中下划线。
|
||||
*
|
||||
* 设计风格:
|
||||
* - 深色背景 #000910
|
||||
* - 强调色 #FF7417(橙色)
|
||||
* - 文字白色透明度分级(100% / 80% / 60% / 40%)
|
||||
* - 金色 #DDB881 用于 VIP 标签
|
||||
* - 卡片圆角约 8dp,焦点态使用白色边框高亮
|
||||
*/
|
||||
object AppColors {
|
||||
val BACKGROUND = Color.parseColor("#000910")
|
||||
val ACCENT = Color.parseColor("#FF7417")
|
||||
val GOLD = Color.parseColor("#DDB881")
|
||||
|
||||
/** 主标题:100% 白色 */
|
||||
val TEXT_PRIMARY = Color.WHITE
|
||||
/** 副标题:80% 白色 */
|
||||
val TEXT_SECONDARY = Color.parseColor("#CCFFFFFF")
|
||||
/** 次要文字:60% 白色 */
|
||||
val TEXT_TERTIARY = Color.parseColor("#99FFFFFF")
|
||||
/** 弱化文字:40% 白色 */
|
||||
val TEXT_WEAK = Color.parseColor("#66FFFFFF")
|
||||
}
|
||||
|
||||
/** dp 转 px */
|
||||
internal fun dp(context: Context, value: Float): Int =
|
||||
(value * context.resources.displayMetrics.density + 0.5f).toInt()
|
||||
|
||||
/**
|
||||
* 为列表项设置焦点高亮:获取焦点时显示白色圆角描边并轻微放大,
|
||||
* 失去焦点时还原。适配 TV/遥控器焦点导航。
|
||||
*/
|
||||
fun applyFocusBorder(
|
||||
view: View,
|
||||
cornerRadiusDp: Float = 10f,
|
||||
strokeDp: Float = 2f,
|
||||
scale: Float = 1.05f
|
||||
) {
|
||||
view.isFocusable = true
|
||||
view.setOnFocusChangeListener { v, hasFocus ->
|
||||
v.foreground = if (hasFocus) {
|
||||
GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(v.context, cornerRadiusDp).toFloat()
|
||||
setStroke(dp(v.context, strokeDp), AppColors.ACCENT)
|
||||
setColor(Color.TRANSPARENT)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
v.scaleX = if (hasFocus) scale else 1f
|
||||
v.scaleY = if (hasFocus) scale else 1f
|
||||
if (hasFocus) v.bringToFront() else v.translationZ = 0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在底部绘制一条彩色线的 Drawable,用于导航选中项的橙色下划线。
|
||||
*/
|
||||
class BottomLineDrawable(
|
||||
color: Int,
|
||||
private val lineHeightPx: Int
|
||||
) : Drawable() {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val b = bounds
|
||||
if (b.height() <= 0 || b.width() <= 0) return
|
||||
val top = (b.bottom - lineHeightPx).toFloat()
|
||||
canvas.drawRect(b.left.toFloat(), top, b.right.toFloat(), b.bottom.toFloat(), paint)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
paint.alpha = alpha
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
paint.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.pukka.mediaapp.ui.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.pukka.mediaapp.databinding.ItemContentBinding
|
||||
import com.pukka.mediaapp.model.ContentItem
|
||||
|
||||
/**
|
||||
* 内容卡片适配器(16:9 横图卡片)。
|
||||
*
|
||||
* - 使用 Glide 加载海报(getPosterUrl())。
|
||||
* - 标题叠加在图片底部渐变遮罩上。
|
||||
* - 卡片宽度由 [cardWidthDp] 控制,适配不同楼层类型。
|
||||
* - 焦点态橙色边框高亮 + 轻微放大。
|
||||
*
|
||||
* @param cardWidthDp 卡片宽度(dp),高度自动按 16:9 计算
|
||||
*/
|
||||
class ContentAdapter(
|
||||
private var cardWidthDp: Int = 200,
|
||||
private val onClick: (ContentItem) -> Unit
|
||||
) : RecyclerView.Adapter<ContentAdapter.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<ContentItem>()
|
||||
|
||||
inner class ViewHolder(val binding: ItemContentBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
applyFocusBorder(itemView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemContentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
/** 更新卡片宽度(楼层类型变化时调用) */
|
||||
fun updateCardWidth(widthDp: Int) {
|
||||
if (widthDp == cardWidthDp) return
|
||||
cardWidthDp = widthDp
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
val widthPx = dp(holder.itemView.context, cardWidthDp.toFloat())
|
||||
val heightPx = widthPx * 9 / 16
|
||||
with(holder.binding) {
|
||||
ivPoster.layoutParams.width = widthPx
|
||||
ivPoster.layoutParams.height = heightPx
|
||||
vGradient.layoutParams.width = widthPx
|
||||
vGradient.layoutParams.height = heightPx / 3 + dp(root.context, 4f)
|
||||
tvName.layoutParams.width = widthPx
|
||||
Glide.with(holder.itemView)
|
||||
.load(item.getPosterUrl())
|
||||
.into(ivPoster)
|
||||
|
||||
tvName.text = item.name
|
||||
tvName.setTextColor(AppColors.TEXT_PRIMARY)
|
||||
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun submitList(list: List<ContentItem>) {
|
||||
items.clear()
|
||||
items.addAll(list)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun addItems(list: List<ContentItem>) {
|
||||
if (list.isEmpty()) return
|
||||
val start = items.size
|
||||
items.addAll(list)
|
||||
notifyItemRangeInserted(start, list.size)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
items.clear()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.pukka.mediaapp.ui.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.pukka.mediaapp.databinding.ItemNavBinding
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.pukka.mediaapp.R
|
||||
import com.pukka.mediaapp.model.NavItem
|
||||
|
||||
/**
|
||||
* 首页左侧导航栏适配器。
|
||||
*
|
||||
* 选中态:文字白色加粗 + 底部橙色下划线;
|
||||
* 未选中态:文字灰色(60% 白)。
|
||||
*/
|
||||
class NavAdapter(
|
||||
private val onClick: (NavItem) -> Unit
|
||||
) : RecyclerView.Adapter<NavAdapter.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<NavItem>()
|
||||
private var selectedPos = RecyclerView.NO_POSITION
|
||||
|
||||
inner class ViewHolder(val binding: ItemNavBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
itemView.isFocusable = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemNavBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
val selected = position == selectedPos
|
||||
with(holder.binding.tvNavName) {
|
||||
text = item.navname
|
||||
paint.isFakeBoldText = selected
|
||||
if (selected) {
|
||||
setTextColor(AppColors.ACCENT)
|
||||
background = ContextCompat.getDrawable(context, R.drawable.bg_nav_selected)
|
||||
} else {
|
||||
setTextColor(AppColors.TEXT_TERTIARY)
|
||||
background = null
|
||||
}
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos == RecyclerView.NO_POSITION || pos == selectedPos) return@setOnClickListener
|
||||
val old = selectedPos
|
||||
selectedPos = pos
|
||||
if (old != RecyclerView.NO_POSITION) notifyItemChanged(old)
|
||||
notifyItemChanged(selectedPos)
|
||||
items.getOrNull(selectedPos)?.let(onClick)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
/** 提交新数据,可指定默认选中位置。 */
|
||||
fun submitList(list: List<NavItem>, selectedIndex: Int = 0) {
|
||||
items.clear()
|
||||
items.addAll(list)
|
||||
selectedPos = if (list.isNotEmpty()) selectedIndex.coerceIn(0, list.size - 1) else RecyclerView.NO_POSITION
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.pukka.mediaapp.ui.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.pukka.mediaapp.databinding.ItemSearchResultBinding
|
||||
import com.pukka.mediaapp.model.ContentItem
|
||||
|
||||
/**
|
||||
* 搜索结果适配器。
|
||||
*
|
||||
* 展示海报、名称、类型/地区、演员,使用 Glide 加载图片,焦点态白色边框高亮。
|
||||
*/
|
||||
class SearchAdapter(
|
||||
private val onClick: (ContentItem) -> Unit
|
||||
) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<ContentItem>()
|
||||
|
||||
inner class ViewHolder(val binding: ItemSearchResultBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
applyFocusBorder(itemView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding =
|
||||
ItemSearchResultBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
with(holder.binding) {
|
||||
Glide.with(holder.itemView)
|
||||
.load(item.getPosterUrl())
|
||||
.into(ivPoster)
|
||||
|
||||
tvName.text = item.name
|
||||
tvName.setTextColor(AppColors.TEXT_PRIMARY)
|
||||
|
||||
tvType.text = item.getTypeText()
|
||||
tvType.setTextColor(AppColors.TEXT_TERTIARY)
|
||||
|
||||
tvActor.text = item.getActorText()
|
||||
tvActor.setTextColor(AppColors.TEXT_SECONDARY)
|
||||
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun submitList(list: List<ContentItem>) {
|
||||
items.clear()
|
||||
items.addAll(list)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.pukka.mediaapp.ui.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.pukka.mediaapp.databinding.ItemSectionBinding
|
||||
import com.pukka.mediaapp.model.ContentItem
|
||||
import com.pukka.mediaapp.model.LayoutSection
|
||||
|
||||
/**
|
||||
* 首页楼层适配器:每个 item 包含一个楼层标题 + 水平内容列表。
|
||||
*
|
||||
* 根据楼层 type 分配不同卡片宽度:
|
||||
* - imgHFloorR1C2: 大 banner(320dp)
|
||||
* - imgSFloorR1C6s / imgHFloorR1C6: 小卡(150dp)
|
||||
* - 其他: 标准卡(200dp)
|
||||
*/
|
||||
class SectionAdapter(
|
||||
private val onContentClick: (ContentItem) -> Unit
|
||||
) : RecyclerView.Adapter<SectionAdapter.ViewHolder>() {
|
||||
|
||||
private val sections = mutableListOf<LayoutSection>()
|
||||
|
||||
inner class ViewHolder(val binding: ItemSectionBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
val contentAdapter = ContentAdapter(onClick = onContentClick)
|
||||
|
||||
init {
|
||||
binding.rvSectionContent.apply {
|
||||
layoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false)
|
||||
adapter = contentAdapter
|
||||
setHasFixedSize(true)
|
||||
isNestedScrollingEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemSectionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val section = sections[position]
|
||||
|
||||
// 过滤布局描述类标题("一行-一拖四"、"二行-一排四个" 等)
|
||||
val title = section.title
|
||||
val isLayoutDesc = title.contains("拖") || title.contains("排") || title.contains("并列")
|
||||
if (isLayoutDesc || title.isEmpty()) {
|
||||
holder.binding.tvSectionTitle.visibility = android.view.View.GONE
|
||||
// 同时隐藏标题行(橙色竖条 + 标题的容器)
|
||||
(holder.binding.tvSectionTitle.parent as? android.view.View)?.visibility = android.view.View.GONE
|
||||
} else {
|
||||
holder.binding.tvSectionTitle.visibility = android.view.View.VISIBLE
|
||||
(holder.binding.tvSectionTitle.parent as? android.view.View)?.visibility = android.view.View.VISIBLE
|
||||
holder.binding.tvSectionTitle.text = title
|
||||
holder.binding.tvSectionTitle.setTextColor(AppColors.TEXT_PRIMARY)
|
||||
}
|
||||
|
||||
// 按楼层类型设置卡片宽度
|
||||
val cardWidth = when {
|
||||
section.type.contains("R1C2") -> 320 // 大 banner
|
||||
section.type.contains("R1C6") -> 150 // 小卡(6 个一行)
|
||||
else -> 200 // 标准卡
|
||||
}
|
||||
holder.contentAdapter.updateCardWidth(cardWidth)
|
||||
holder.contentAdapter.submitList(section.extraData ?: emptyList())
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = sections.size
|
||||
|
||||
fun submitList(list: List<LayoutSection>) {
|
||||
sections.clear()
|
||||
sections.addAll(list)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.pukka.mediaapp.ui.adapter
|
||||
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.pukka.mediaapp.databinding.ItemTagBinding
|
||||
import com.pukka.mediaapp.model.TagItem
|
||||
|
||||
/**
|
||||
* 分类标签适配器。
|
||||
*
|
||||
* 选中态:橙色圆角背景 + 白色加粗文字;
|
||||
* 未选中态:半透明白色圆角背景 + 80% 白色文字。
|
||||
*/
|
||||
class TagAdapter(
|
||||
private val onClick: (TagItem) -> Unit
|
||||
) : RecyclerView.Adapter<TagAdapter.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<TagItem>()
|
||||
private var selectedPos = 0
|
||||
|
||||
inner class ViewHolder(val binding: ItemTagBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
itemView.isFocusable = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemTagBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
val selected = position == selectedPos
|
||||
with(holder.binding.tvTagName) {
|
||||
text = item.name
|
||||
paint.isFakeBoldText = selected
|
||||
setTextColor(if (selected) AppColors.TEXT_PRIMARY else AppColors.TEXT_SECONDARY)
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(context, 16f).toFloat()
|
||||
setColor(if (selected) AppColors.ACCENT else 0x22FFFFFF)
|
||||
}
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos == RecyclerView.NO_POSITION || pos == selectedPos) return@setOnClickListener
|
||||
val old = selectedPos
|
||||
selectedPos = pos
|
||||
if (old != RecyclerView.NO_POSITION) notifyItemChanged(old)
|
||||
notifyItemChanged(selectedPos)
|
||||
items.getOrNull(selectedPos)?.let(onClick)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun submitList(list: List<TagItem>) {
|
||||
items.clear()
|
||||
items.addAll(list)
|
||||
selectedPos = if (list.isNotEmpty()) 0 else RecyclerView.NO_POSITION
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/com/pukka/mediaapp/utils/DeviceInfoUtil.kt
Normal file
@@ -0,0 +1,237 @@
|
||||
package com.pukka.mediaapp.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.NetworkInterface
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 设备标识采集工具
|
||||
*
|
||||
* 采用多标识逐步降权策略,适配 Android TV 盒子:
|
||||
*
|
||||
* | 标识 | 来源 | 权限 | 重装不变 | 说明 |
|
||||
* |-----------|-------------------------------|---------|---------|--------------------------|
|
||||
* | MAC | NetworkInterface / sys 节点 | 无 | 是 | eth0(有线) / wlan0(WiFi) |
|
||||
* | deviceSn | getprop ro.serialno | 无 | 是 | TV 盒子系统属性 |
|
||||
* | androidId | Settings.Secure.ANDROID_ID | 无 | 是 | Android 8+ 按签名键隔离 |
|
||||
* | deviceUid | UUID 存 SharedPreferences | 无 | 否 | 兜底,始终有值 |
|
||||
*
|
||||
* init 请求同时传递所有字段,后端按优先级降权匹配设备。
|
||||
*/
|
||||
object DeviceInfoUtil {
|
||||
|
||||
private const val TAG = "DeviceInfoUtil"
|
||||
private const val PREFS_NAME = "device_info"
|
||||
private const val KEY_UID = "uid"
|
||||
|
||||
data class DeviceIdentity(
|
||||
val mac: String?, // 有线 MAC(eth0 优先)
|
||||
val wifiMac: String?, // WiFi MAC(wlan0)
|
||||
val deviceSn: String?, // 设备序列号(getprop ro.serialno 优先)
|
||||
val deviceUid: String, // Android ID 优先,UUID 兜底(始终有值)
|
||||
val androidId: String?, // Settings.Secure.ANDROID_ID(独立字段,便于后端区分)
|
||||
val manufacturer: String?, // 厂商(Build.MANUFACTURER)
|
||||
val model: String?, // 型号(Build.MODEL)
|
||||
val brand: String? // 品牌(Build.BRAND)
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取设备标识(所有字段同时返回,后端按优先级降权匹配)
|
||||
*/
|
||||
fun getDeviceIdentity(context: Context): DeviceIdentity {
|
||||
val ethMac = getMacFromInterface("eth0") ?: getMacFromSysFile("eth0") ?: getMacFromInterface("eth")
|
||||
val wifiMac = getMacFromInterface("wlan0") ?: getMacFromSysFile("wlan0") ?: getMacFromInterface("wlan")
|
||||
|
||||
// MAC 兜底1:遍历所有非 lo 接口
|
||||
val fallbackMac = if (ethMac == null && wifiMac == null) getFirstAvailableMac() else null
|
||||
var mac = ethMac ?: fallbackMac
|
||||
|
||||
val sn = getDeviceSn()
|
||||
val androidId = getAndroidId(context)
|
||||
val uid = getDeviceUid(context, androidId)
|
||||
|
||||
// MAC 兜底2:真实 MAC 拿不到时,用 androidId 派生稳定伪 MAC(02: 前缀标识非硬件)
|
||||
if (mac == null && !androidId.isNullOrEmpty()) {
|
||||
mac = generatePseudoMac(androidId)
|
||||
Log.d(TAG, "真实 MAC 不可用,使用 androidId 派生伪 MAC: $mac")
|
||||
}
|
||||
|
||||
val identity = DeviceIdentity(
|
||||
mac = mac,
|
||||
wifiMac = wifiMac,
|
||||
deviceSn = sn,
|
||||
deviceUid = uid,
|
||||
androidId = androidId,
|
||||
manufacturer = Build.MANUFACTURER,
|
||||
model = Build.MODEL,
|
||||
brand = Build.BRAND
|
||||
)
|
||||
|
||||
val macSource = if (mac != null && mac.startsWith("02:")) "pseudo" else if (mac != null) "real" else "null"
|
||||
Log.d(TAG, "DeviceIdentity: mac=$mac($macSource) wifiMac=${identity.wifiMac} sn=$sn " +
|
||||
"androidId=$androidId uid=$uid brand=${identity.brand} model=${identity.model}")
|
||||
FileLogger.log(TAG, "DeviceIdentity: mac=$mac($macSource) wifiMac=${identity.wifiMac} " +
|
||||
"sn=$sn androidId=$androidId uid=$uid manufacturer=${identity.manufacturer} model=${identity.model}")
|
||||
|
||||
return identity
|
||||
}
|
||||
|
||||
// ===== MAC 地址 =====
|
||||
|
||||
/** 从 NetworkInterface 获取指定接口的 MAC */
|
||||
private fun getMacFromInterface(ifacePrefix: String): String? {
|
||||
return try {
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return null
|
||||
for (nif in interfaces) {
|
||||
if (!nif.name.lowercase().startsWith(ifacePrefix)) continue
|
||||
val macBytes = nif.hardwareAddress ?: continue
|
||||
val mac = macBytes.joinToString(":") { "%02X".format(it) }
|
||||
if (mac.isNotEmpty() && mac != "02:00:00:00:00:00") {
|
||||
return mac
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 /sys/class/net/{iface}/address 读取 MAC */
|
||||
private fun getMacFromSysFile(iface: String): String? {
|
||||
return try {
|
||||
val reader = FileReader("/sys/class/net/$iface/address")
|
||||
val mac = reader.readText().trim().uppercase()
|
||||
reader.close()
|
||||
if (mac.isNotEmpty() && mac != "02:00:00:00:00:00") mac else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 遍历所有非 lo 接口,返回第一个有效 MAC */
|
||||
private fun getFirstAvailableMac(): String? {
|
||||
return try {
|
||||
// 先试 NetworkInterface
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return null
|
||||
for (nif in interfaces) {
|
||||
val name = nif.name.lowercase()
|
||||
if (name.startsWith("lo")) continue
|
||||
val macBytes = nif.hardwareAddress ?: continue
|
||||
val mac = macBytes.joinToString(":") { "%02X".format(it) }
|
||||
if (mac.isNotEmpty() && mac != "02:00:00:00:00:00") {
|
||||
return mac
|
||||
}
|
||||
}
|
||||
// 再试 sys 文件
|
||||
for (iface in listOf("eth0", "wlan0", "eth1", "wlan1")) {
|
||||
getMacFromSysFile(iface)?.let { return it }
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 设备序列号 =====
|
||||
|
||||
/**
|
||||
* 获取设备 SN
|
||||
* 策略:getprop ro.serialno(TV 盒子可用)-> Build.getSerial()(需特权,大概率失败)
|
||||
*/
|
||||
private fun getDeviceSn(): String? {
|
||||
// 方式1: getprop ro.serialno(大多数 TV 盒子可用,无需特权)
|
||||
val propSn = getSystemProperty("ro.serialno")
|
||||
if (!propSn.isNullOrEmpty() && propSn != "unknown") return propSn
|
||||
|
||||
// 方式2: getprop ro.boot.serialno
|
||||
val bootSn = getSystemProperty("ro.boot.serialno")
|
||||
if (!bootSn.isNullOrEmpty() && bootSn != "unknown") return bootSn
|
||||
|
||||
// 方式3: Build.getSerial()(Android 8+ 需 READ_PRIVILEGED_PHONE_STATE,大概率失败)
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serial = Build.getSerial()
|
||||
if (!serial.isNullOrEmpty() && serial != Build.UNKNOWN) serial else null
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val serial = Build.SERIAL
|
||||
if (!serial.isNullOrEmpty() && serial != Build.UNKNOWN) serial else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "getDeviceSn Build.getSerial failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过 getprop 读取系统属性 */
|
||||
private fun getSystemProperty(propName: String): String? {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("getprop", propName))
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val value = reader.readLine()?.trim()
|
||||
reader.close()
|
||||
process.waitFor()
|
||||
value
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 伪 MAC 派生 =====
|
||||
|
||||
/**
|
||||
* 用 androidId 派生一个稳定的伪 MAC 地址
|
||||
* - 02: 前缀表示本地管理地址(非硬件 MAC),便于后端区分
|
||||
* - 取 androidId 前 10 位 hex(5 字节)+ 02 前缀 = 6 字节 MAC
|
||||
* - 同一设备重装不变(androidId 稳定),不同设备不同
|
||||
*/
|
||||
private fun generatePseudoMac(androidId: String): String {
|
||||
val hex = androidId.take(10).padEnd(10, '0')
|
||||
return ("02" + hex).chunked(2).joinToString(":") { it.uppercase() }
|
||||
}
|
||||
|
||||
// ===== Android ID =====
|
||||
|
||||
/**
|
||||
* 获取 Android ID(Settings.Secure.ANDROID_ID)
|
||||
* - 无需权限
|
||||
* - 工厂重置才变,App 重装不变
|
||||
* - Android 8+ 按签名键隔离,同一 App 在同一设备上值稳定
|
||||
*/
|
||||
private fun getAndroidId(context: Context): String? {
|
||||
return try {
|
||||
val id = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
if (id.isNullOrEmpty() || id == "9774d56d682e549c") null else id
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getAndroidId failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 设备 UID(兜底) =====
|
||||
|
||||
/**
|
||||
* 获取设备 UID(始终有值)
|
||||
* 优先使用 Android ID,不可用时生成 UUID 存 SharedPreferences
|
||||
*/
|
||||
private fun getDeviceUid(context: Context, androidId: String?): String {
|
||||
// Android ID 可用直接用
|
||||
if (!androidId.isNullOrEmpty()) return androidId
|
||||
|
||||
// 兜底:UUID 存 SharedPreferences
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
var uid = prefs.getString(KEY_UID, null)
|
||||
if (uid == null) {
|
||||
uid = UUID.randomUUID().toString().replace("-", "")
|
||||
prefs.edit().putString(KEY_UID, uid).apply()
|
||||
Log.d(TAG, "Generated new device UID (UUID fallback): $uid")
|
||||
}
|
||||
return uid
|
||||
}
|
||||
}
|
||||
105
app/src/main/java/com/pukka/mediaapp/utils/FileLogger.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
package com.pukka.mediaapp.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 文件日志工具
|
||||
*
|
||||
* 将关键错误/诊断信息写入应用专属外部目录下的日志文件,
|
||||
* 便于 adb pull 或文件管理器导出分析(无需存储权限)。
|
||||
*
|
||||
* 日志路径:Android/data/com.pukka.mediaapp/files/logs/daycard.log
|
||||
*/
|
||||
object FileLogger {
|
||||
|
||||
private const val TAG = "FileLogger"
|
||||
private const val LOG_DIR = "logs"
|
||||
private const val LOG_FILE = "daycard.log"
|
||||
private const val MAX_FILE_SIZE = 2 * 1024 * 1024L // 2MB,超出则重置
|
||||
|
||||
private lateinit var appContext: Context
|
||||
private val lock = Any()
|
||||
|
||||
/** Application 启动时调用,初始化上下文 */
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
log("FileLogger", "FileLogger initialized, log dir=${getLogDir()?.absolutePath}")
|
||||
}
|
||||
|
||||
/** 写入一条 INFO 级别日志 */
|
||||
fun log(tag: String, message: String) {
|
||||
write("I", tag, message, null)
|
||||
}
|
||||
|
||||
/** 写入一条 ERROR 级别日志(含异常堆栈) */
|
||||
fun error(tag: String, message: String, throwable: Throwable? = null) {
|
||||
write("E", tag, message, throwable)
|
||||
}
|
||||
|
||||
private fun write(level: String, tag: String, message: String, throwable: Throwable?) {
|
||||
if (!::appContext.isInitialized) return
|
||||
synchronized(lock) {
|
||||
try {
|
||||
val dir = getLogDir() ?: return
|
||||
val file = File(dir, LOG_FILE)
|
||||
// 超过上限时清空,避免无限增长
|
||||
if (file.exists() && file.length() > MAX_FILE_SIZE) {
|
||||
file.writeText("")
|
||||
}
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
.format(Date())
|
||||
val sb = StringBuilder()
|
||||
sb.append("$timestamp $level/$tag: $message")
|
||||
throwable?.let {
|
||||
sb.append("\n")
|
||||
val sw = StringWriter()
|
||||
it.printStackTrace(PrintWriter(sw))
|
||||
sb.append(sw.toString())
|
||||
}
|
||||
sb.append("\n")
|
||||
file.appendText(sb.toString())
|
||||
// 同时输出到 logcat
|
||||
if (level == "E") {
|
||||
Log.e(tag, message, throwable)
|
||||
} else {
|
||||
Log.d(tag, message)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "write log failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLogDir(): File? {
|
||||
return try {
|
||||
// 优先使用外部专属目录(adb 可直接 pull,无需权限)
|
||||
val dir = appContext.getExternalFilesDir(null)
|
||||
?: appContext.filesDir
|
||||
val logDir = File(dir, LOG_DIR)
|
||||
if (!logDir.exists()) logDir.mkdirs()
|
||||
logDir
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "getLogDir failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取当前日志内容(用于页面展示/分享) */
|
||||
fun readLog(): String {
|
||||
if (!::appContext.isInitialized) return ""
|
||||
return try {
|
||||
val file = File(getLogDir() ?: return "", LOG_FILE)
|
||||
if (file.exists()) file.readText() else ""
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "readLog failed", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
40
app/src/main/java/com/pukka/mediaapp/utils/QRCodeUtil.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
package com.pukka.mediaapp.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
|
||||
/**
|
||||
* 二维码生成工具
|
||||
*
|
||||
* 用于将微信支付 codeUrl 生成二维码 Bitmap 展示在弹窗中。
|
||||
*/
|
||||
object QRCodeUtil {
|
||||
|
||||
/**
|
||||
* 根据内容生成二维码 Bitmap
|
||||
* @param content 二维码内容(如 weixin://wxpay/bizpayurl?pr=xxxxx)
|
||||
* @param size 图片尺寸(像素),默认 400
|
||||
* @return 二维码 Bitmap
|
||||
*/
|
||||
fun generateQRCode(content: String, size: Int = 400): Bitmap {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.CHARACTER_SET to "UTF-8",
|
||||
EncodeHintType.MARGIN to 1,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||
)
|
||||
val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val width = bitMatrix.width
|
||||
val height = bitMatrix.height
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bitmap.setPixel(x, y, if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.pukka.mediaapp.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.view.ViewCompat;
|
||||
|
||||
import com.pukka.mediaapp.R;
|
||||
|
||||
/**
|
||||
* Author: wxf
|
||||
* Date: 2023/1/12 10:48
|
||||
* Description:Top样式切换
|
||||
*/
|
||||
public class TopStyleUtils {
|
||||
// 搜索、历史、订购、帮助 样式切换
|
||||
public static void topStyleChange(Context context, View view, boolean focus) {
|
||||
// ConstraintLayout constraintLayout = (ConstraintLayout) view;
|
||||
// View icon = constraintLayout.getChildAt(0);
|
||||
// icon.setSelected(focus);
|
||||
// TextView textView = (TextView) constraintLayout.getChildAt(1);
|
||||
if (focus) {
|
||||
view.animate().scaleX(1.1f).scaleY(1.1f).setDuration(100).start();
|
||||
// textView.setTextColor(context.getResources().getColor(R.color.index_top_text_focus));
|
||||
// textView.getPaint().setFakeBoldText(true);
|
||||
} else {
|
||||
view.animate().scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
|
||||
// textView.setTextColor(context.getResources().getColor(R.color.index_top_text_nor));
|
||||
// textView.getPaint().setFakeBoldText(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static void topImageChange(View view, boolean focus) {
|
||||
if (focus) {
|
||||
ViewCompat.animate(view).scaleX(1.05f).scaleY(1.05f).setDuration(100).start();
|
||||
} else {
|
||||
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
|
||||
}
|
||||
}
|
||||
|
||||
public static void topImageChangeFilter(View view, boolean focus) {
|
||||
if (focus) {
|
||||
ViewCompat.animate(view).scaleX(1.02f).scaleY(1.02f).setDuration(100).start();
|
||||
} else {
|
||||
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 一行的大模板 放大 防止焦点框外溢
|
||||
* */
|
||||
public static void topImageChange3(View view, boolean focus) {
|
||||
if (focus) {
|
||||
ViewCompat.animate(view).scaleX(1.02f).scaleY(1.05f).setDuration(100).start();
|
||||
} else {
|
||||
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 对view放大1.05f
|
||||
*
|
||||
* @param view view
|
||||
* @param focus focus
|
||||
*/
|
||||
public static void topImageChange2(View view, boolean focus) {
|
||||
if (focus) {
|
||||
ViewCompat.animate(view).scaleX(1.03f).scaleY(1.05f).setDuration(100).start();
|
||||
} else {
|
||||
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
|
||||
}
|
||||
}
|
||||
public static void topImageChange4(View view, boolean focus) {
|
||||
if (focus) {
|
||||
ViewCompat.animate(view).scaleX(1.03f).scaleY(1.03f).setDuration(100).start();
|
||||
} else {
|
||||
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.pukka.mediaapp.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pukka.mediaapp.model.ContentItem
|
||||
import com.pukka.mediaapp.network.MediaRepository
|
||||
import com.pukka.mediaapp.utils.FileLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 详情页 ViewModel:根据 contentcode 加载媒资详情。
|
||||
*/
|
||||
class DetailViewModel : ViewModel() {
|
||||
|
||||
private val repository = MediaRepository.getInstance()
|
||||
|
||||
private val _detail = MutableStateFlow<ContentItem?>(null)
|
||||
val detail: StateFlow<ContentItem?> = _detail.asStateFlow()
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
/** 加载内容详情。 */
|
||||
fun loadDetail(contentcode: String) {
|
||||
if (contentcode.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
repository.getContentDetail(contentcode)
|
||||
.onSuccess { response ->
|
||||
val item = response.result?.firstOrNull()
|
||||
_detail.value = item
|
||||
FileLogger.log("DetailVM", "详情加载: contentcode=$contentcode name=${item?.name} hasData=${item != null}")
|
||||
}
|
||||
.onFailure {
|
||||
_error.value = it.message ?: "加载详情失败"
|
||||
}
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.pukka.mediaapp.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pukka.mediaapp.model.LayoutSection
|
||||
import com.pukka.mediaapp.model.NavItem
|
||||
import com.pukka.mediaapp.network.MediaRepository
|
||||
import com.pukka.mediaapp.utils.FileLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 首页 ViewModel:负责导航栏数据与二级布局楼层数据。
|
||||
*/
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val repository = MediaRepository.getInstance()
|
||||
|
||||
private val _navItems = MutableStateFlow<List<NavItem>>(emptyList())
|
||||
val navItems: StateFlow<List<NavItem>> = _navItems.asStateFlow()
|
||||
|
||||
private val _sections = MutableStateFlow<List<LayoutSection>>(emptyList())
|
||||
val sections: StateFlow<List<LayoutSection>> = _sections.asStateFlow()
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
/** 默认选中的导航项位置 */
|
||||
private val _selectedNavIndex = MutableStateFlow(0)
|
||||
val selectedNavIndex: StateFlow<Int> = _selectedNavIndex.asStateFlow()
|
||||
|
||||
private var hasLoadedNav = false
|
||||
|
||||
/** 加载首页导航,加载成功后默认请求 isFocus=1 的导航项对应的布局。 */
|
||||
fun loadNav() {
|
||||
if (hasLoadedNav) return
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
repository.getLayoutNav()
|
||||
.onSuccess { response ->
|
||||
val allItems = response.data
|
||||
?.flatMap { it.navList ?: emptyList() }
|
||||
?: emptyList()
|
||||
|
||||
// 只保留有布局数据的标签(type=1 为内容页)
|
||||
val contentItems = allItems.filter { it.type == 1 }
|
||||
_navItems.value = contentItems
|
||||
FileLogger.log("HomeVM", "导航加载: ${contentItems.size} 个标签, 默认选中=${contentItems.indexOfFirst { it.isFocus == "1" }}")
|
||||
hasLoadedNav = true
|
||||
if (contentItems.isNotEmpty()) {
|
||||
// 优先选 isFocus=="1" 的,否则选第一个
|
||||
val defaultIndex = contentItems.indexOfFirst { it.isFocus == "1" }
|
||||
val targetIndex = if (defaultIndex >= 0) defaultIndex else 0
|
||||
_selectedNavIndex.value = targetIndex
|
||||
loadLayout(contentItems[targetIndex].navid)
|
||||
} else {
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
_error.value = it.message ?: "加载导航失败"
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据 navid 加载二级页面布局(楼层列表)。 */
|
||||
fun loadLayout(navid: String) {
|
||||
if (navid.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
repository.getLayout(navid)
|
||||
.onSuccess { response ->
|
||||
val sections = response.data ?: emptyList()
|
||||
Log.d("HomeViewModel", "loadLayout success: ${sections.size} sections")
|
||||
FileLogger.log("HomeVM", "布局加载: navid=$navid ${sections.size} 个楼层")
|
||||
_sections.value = sections
|
||||
}
|
||||
.onFailure {
|
||||
Log.e("HomeViewModel", "loadLayout failed", it)
|
||||
_error.value = it.message ?: "加载内容失败"
|
||||
_sections.value = emptyList()
|
||||
}
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新选中的导航索引 */
|
||||
fun selectNav(index: Int) {
|
||||
_selectedNavIndex.value = index
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.pukka.mediaapp.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pukka.mediaapp.model.ContentItem
|
||||
import com.pukka.mediaapp.model.TagItem
|
||||
import com.pukka.mediaapp.network.MediaRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 列表页 ViewModel:负责左侧分类标签与右侧内容网格(含分页)。
|
||||
*/
|
||||
class ListViewModel : ViewModel() {
|
||||
|
||||
private val repository = MediaRepository.getInstance()
|
||||
|
||||
private val _tags = MutableStateFlow<List<TagItem>>(emptyList())
|
||||
val tags: StateFlow<List<TagItem>> = _tags.asStateFlow()
|
||||
|
||||
private val _contentList = MutableStateFlow<List<ContentItem>>(emptyList())
|
||||
val contentList: StateFlow<List<ContentItem>> = _contentList.asStateFlow()
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading.asStateFlow()
|
||||
|
||||
private val _hasMore = MutableStateFlow(true)
|
||||
val hasMore: StateFlow<Boolean> = _hasMore.asStateFlow()
|
||||
|
||||
private var catalogid: String = ""
|
||||
private var currentPage = 1
|
||||
private var isLoading = false
|
||||
|
||||
/** 初始化:加载标签与第一页内容。重复调用相同 catalogid 会被忽略。 */
|
||||
fun init(catalogid: String) {
|
||||
if (this.catalogid == catalogid && _contentList.value.isNotEmpty()) return
|
||||
this.catalogid = catalogid
|
||||
loadTags(catalogid)
|
||||
loadContent(reset = true)
|
||||
}
|
||||
|
||||
/** 加载分类标签。 */
|
||||
fun loadTags(parentid: String) {
|
||||
viewModelScope.launch {
|
||||
repository.getTagsList(parentid)
|
||||
.onSuccess { response -> _tags.value = response.result ?: emptyList() }
|
||||
.onFailure { _tags.value = emptyList() }
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载内容列表,reset=true 表示重新从第一页加载(用于切换筛选)。 */
|
||||
fun loadContent(reset: Boolean = false) {
|
||||
if (catalogid.isEmpty()) return
|
||||
if (isLoading) return
|
||||
if (reset) {
|
||||
currentPage = 1
|
||||
_hasMore.value = true
|
||||
} else if (!_hasMore.value) {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
_loading.value = true
|
||||
viewModelScope.launch {
|
||||
repository.getContentList(catalogid, currentPage)
|
||||
.onSuccess { response ->
|
||||
val list = response.result ?: emptyList()
|
||||
_contentList.value = if (reset) list else _contentList.value + list
|
||||
currentPage++
|
||||
_hasMore.value = currentPage <= response.pagetotal
|
||||
}
|
||||
.onFailure {
|
||||
if (!reset) _hasMore.value = false
|
||||
}
|
||||
_loading.value = false
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.pukka.mediaapp.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pukka.mediaapp.config.DayCardConfig
|
||||
import com.pukka.mediaapp.network.DayCardRetrofitClient
|
||||
import com.pukka.mediaapp.network.SignManager
|
||||
import com.pukka.mediaapp.utils.FileLogger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
||||
/**
|
||||
* 支付流程状态(对齐生产方案:checkBenefits 返回 thirdCardNo 直接播放)
|
||||
*/
|
||||
sealed class PaymentState {
|
||||
object Idle : PaymentState()
|
||||
object CheckingPermission : PaymentState()
|
||||
/** 有天卡权益,可用 thirdCardNo 播放 */
|
||||
data class HasPermission(val thirdCardNo: String) : PaymentState()
|
||||
object CreatingOrder : PaymentState()
|
||||
/** 等待支付(展示二维码) */
|
||||
data class WaitingForPayment(
|
||||
val orderNo: String,
|
||||
val amount: Int, // 金额(分)
|
||||
val codeUrl: String, // 微信支付二维码链接
|
||||
val expireAt: String // 二维码过期时间
|
||||
) : PaymentState()
|
||||
/** 支付成功,可用 thirdCardNo 播放 */
|
||||
data class PaymentSuccess(val thirdCardNo: String) : PaymentState()
|
||||
object PaymentTimeout : PaymentState()
|
||||
data class Error(val message: String) : PaymentState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 天卡支付 ViewModel(对齐生产方案)
|
||||
*
|
||||
* 完整流程:
|
||||
* 1. ensureSign → 保证 sign 有效(必要时自动 init / refresh)
|
||||
* 2. checkBenefits → ACTIVE 则用 thirdCardNo 播放
|
||||
* 3. UNBOUND → createOrder → 展示微信支付二维码
|
||||
* 4. 轮询 getOrderStatus → PAID → 再 checkBenefits 拿 thirdCardNo → 播放
|
||||
* 5. CANCELLED/超时 → 展示重新生成
|
||||
*
|
||||
* 设备标识不再由 App 每次传递,init 时传一次,后续从 sign 解析。
|
||||
*/
|
||||
class PaymentViewModel : ViewModel() {
|
||||
|
||||
private val api = DayCardRetrofitClient.apiService
|
||||
|
||||
private val _state = MutableStateFlow<PaymentState>(PaymentState.Idle)
|
||||
val state: StateFlow<PaymentState> = _state.asStateFlow()
|
||||
|
||||
private val _orderNo = MutableStateFlow("")
|
||||
val orderNo: StateFlow<String> = _orderNo.asStateFlow()
|
||||
|
||||
private var pollJob: Job? = null
|
||||
private var flowJob: Job? = null
|
||||
|
||||
/**
|
||||
* 开始支付/鉴权流程。
|
||||
* mediaId 不再传给 checkBenefits(checkBenefits 返回设备级权益)。
|
||||
*/
|
||||
fun startPaymentFlow() {
|
||||
cancelAllJobs()
|
||||
_state.value = PaymentState.Idle
|
||||
|
||||
// 权益缓存有效,直接播放(静默,不鉴权、不弹窗)
|
||||
val cached = SignManager.getCachedThirdCardNo()
|
||||
if (cached.isNotEmpty()) {
|
||||
FileLogger.log("PaymentVM", "使用缓存权益播放: thirdCardNo=$cached")
|
||||
_state.value = PaymentState.HasPermission(cached)
|
||||
return
|
||||
}
|
||||
|
||||
flowJob = viewModelScope.launch {
|
||||
_state.value = PaymentState.CheckingPermission
|
||||
try {
|
||||
// Step1: 确保 sign 有效
|
||||
SignManager.ensureSign()
|
||||
|
||||
// Step2: 查询天卡权益
|
||||
val benefits = checkBenefitsWithRetry()
|
||||
FileLogger.log("PaymentVM", "checkBenefits: code=${benefits.code} msg=${benefits.msg} status=${benefits.status} isActive=${benefits.isActive} thirdCardNo=${benefits.thirdCardNo}")
|
||||
if (benefits.isActive && benefits.thirdCardNo.isNotEmpty()) {
|
||||
SignManager.cacheBenefits(benefits.thirdCardNo, benefits.expireAt)
|
||||
_state.value = PaymentState.HasPermission(benefits.thirdCardNo)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Step3: 无权益 → 创建订单
|
||||
_state.value = PaymentState.CreatingOrder
|
||||
val orderResp = createOrderWithRetry()
|
||||
FileLogger.log("PaymentVM", "createOrder: code=${orderResp.code} msg=${orderResp.msg} orderNo=${orderResp.orderNo} codeUrl=${orderResp.codeUrl} amount=${orderResp.amount}")
|
||||
if (!orderResp.isSuccess || orderResp.orderNo.isEmpty()) {
|
||||
FileLogger.error("PaymentVM", "createOrder 业务失败: code=${orderResp.code} msg=${orderResp.msg}")
|
||||
_state.value = PaymentState.Error(orderResp.msg.ifEmpty { "创建订单失败" })
|
||||
return@launch
|
||||
}
|
||||
|
||||
_orderNo.value = orderResp.orderNo
|
||||
|
||||
if (orderResp.codeUrl.isEmpty()) {
|
||||
FileLogger.error("PaymentVM", "createOrder codeUrl 为空")
|
||||
_state.value = PaymentState.Error("未获取到支付二维码,请重试")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Step4: 展示二维码,等待支付
|
||||
_state.value = PaymentState.WaitingForPayment(
|
||||
orderNo = orderResp.orderNo,
|
||||
amount = orderResp.amount,
|
||||
codeUrl = orderResp.codeUrl,
|
||||
expireAt = orderResp.expireAt
|
||||
)
|
||||
|
||||
// Step5: 轮询订单状态
|
||||
startPollingOrderStatus(orderResp.orderNo)
|
||||
|
||||
} catch (e: CancellationException) {
|
||||
// 正常取消
|
||||
} catch (e: Exception) {
|
||||
Log.e("PaymentVM", "startPaymentFlow failed", e)
|
||||
FileLogger.error("PaymentVM", "startPaymentFlow 异常: ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
_state.value = PaymentState.Error(e.message ?: "网络错误")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询权益,401 时重新 init 后重试一次 */
|
||||
private suspend fun checkBenefitsWithRetry() =
|
||||
checkBenefitsWithRetry(0)
|
||||
|
||||
private suspend fun checkBenefitsWithRetry(retried: Int): com.pukka.mediaapp.model.daycard.CheckBenefitsResponse {
|
||||
return try {
|
||||
api.checkBenefits()
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() == 401 && retried == 0) {
|
||||
Log.w("PaymentVM", "checkBenefits 401, re-init and retry")
|
||||
SignManager.doInit()
|
||||
checkBenefitsWithRetry(1)
|
||||
} else throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建订单,401 时重新 init 后重试一次 */
|
||||
private suspend fun createOrderWithRetry() =
|
||||
createOrderWithRetry(0)
|
||||
|
||||
private suspend fun createOrderWithRetry(retried: Int): com.pukka.mediaapp.model.daycard.CreateOrderResponse {
|
||||
return try {
|
||||
api.createOrder()
|
||||
} catch (e: HttpException) {
|
||||
if (e.code() == 401 && retried == 0) {
|
||||
Log.w("PaymentVM", "createOrder 401, re-init and retry")
|
||||
SignManager.doInit()
|
||||
createOrderWithRetry(1)
|
||||
} else throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询订单状态,直到 PAID / CANCELLED / 超时
|
||||
*/
|
||||
private fun startPollingOrderStatus(orderNo: String) {
|
||||
pollJob = viewModelScope.launch {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
while (isActive) {
|
||||
if (System.currentTimeMillis() - startTime >= DayCardConfig.POLL_TIMEOUT) {
|
||||
_state.value = PaymentState.PaymentTimeout
|
||||
return@launch
|
||||
}
|
||||
|
||||
delay(DayCardConfig.POLL_INTERVAL)
|
||||
|
||||
try {
|
||||
val response = api.getOrderStatus(orderNo)
|
||||
if (response.isSuccess) {
|
||||
when (response.status) {
|
||||
"PAID" -> {
|
||||
// 支付成功后查询权益拿 thirdCardNo
|
||||
val benefits = try { api.checkBenefits() } catch (e: Exception) {
|
||||
Log.e("PaymentVM", "post-pay checkBenefits failed", e)
|
||||
null
|
||||
}
|
||||
if (benefits != null && benefits.isActive && benefits.thirdCardNo.isNotEmpty()) {
|
||||
SignManager.cacheBenefits(benefits.thirdCardNo, benefits.expireAt)
|
||||
_state.value = PaymentState.PaymentSuccess(benefits.thirdCardNo)
|
||||
} else {
|
||||
// 权益未生效,重试一次
|
||||
delay(2000)
|
||||
val retry = try { api.checkBenefits() } catch (_: Exception) { null }
|
||||
if (retry != null && retry.isActive && retry.thirdCardNo.isNotEmpty()) {
|
||||
SignManager.cacheBenefits(retry.thirdCardNo, retry.expireAt)
|
||||
_state.value = PaymentState.PaymentSuccess(retry.thirdCardNo)
|
||||
} else {
|
||||
_state.value = PaymentState.Error("支付成功但权益未生效,请稍后重试")
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
"CANCELLED" -> {
|
||||
_state.value = PaymentState.PaymentTimeout
|
||||
return@launch
|
||||
}
|
||||
// CREATED / PAYING → 继续轮询
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
return@launch
|
||||
} catch (e: Exception) {
|
||||
Log.e("PaymentVM", "pollOrderStatus error", e)
|
||||
// 网络错误不中断轮询
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
}
|
||||
|
||||
private fun cancelAllJobs() {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
flowJob?.cancel()
|
||||
flowJob = null
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
cancelAllJobs()
|
||||
_state.value = PaymentState.Idle
|
||||
_orderNo.value = ""
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
cancelAllJobs()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.pukka.mediaapp.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pukka.mediaapp.model.ContentItem
|
||||
import com.pukka.mediaapp.network.MediaRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 搜索页 ViewModel:根据关键词搜索媒资。
|
||||
*/
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private val repository = MediaRepository.getInstance()
|
||||
|
||||
private val _results = MutableStateFlow<List<ContentItem>>(emptyList())
|
||||
val results: StateFlow<List<ContentItem>> = _results.asStateFlow()
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading.asStateFlow()
|
||||
|
||||
private val _empty = MutableStateFlow(false)
|
||||
val empty: StateFlow<Boolean> = _empty.asStateFlow()
|
||||
|
||||
/** 执行搜索。空关键词清空结果。 */
|
||||
fun search(keyword: String) {
|
||||
val kw = keyword.trim()
|
||||
if (kw.isEmpty()) {
|
||||
_results.value = emptyList()
|
||||
_empty.value = false
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
repository.search(kw)
|
||||
.onSuccess { response ->
|
||||
val list = response.result ?: emptyList()
|
||||
_results.value = list
|
||||
_empty.value = list.isEmpty()
|
||||
}
|
||||
.onFailure {
|
||||
_results.value = emptyList()
|
||||
_empty.value = true
|
||||
}
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @file ActivationCodeInputView.kt
|
||||
* @description 激活码输入独立视图,负责键盘输入、提示态与自动提交
|
||||
* @author ainetv
|
||||
* @date 2026-03-30
|
||||
*/
|
||||
package com.pukka.mediaapp.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
|
||||
class ActivationCodeInputView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LocationCodeInputView(context, attrs, defStyleAttr) {
|
||||
init {
|
||||
setInputMode(InputMode.ACTIVATION)
|
||||
}
|
||||
|
||||
fun setOnInputChangedListener(listener: ((String) -> Unit)?) {
|
||||
setOnActivationChangedListener(listener)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file ActivationFocusRecyclerView.java
|
||||
* @description 激活流程专用焦点 RecyclerView,作为激活页独立类型入口
|
||||
* @author ainetv
|
||||
* @date 2026-04-04
|
||||
*/
|
||||
package com.pukka.mediaapp.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
public class ActivationFocusRecyclerView extends RecyclerView {
|
||||
public ActivationFocusRecyclerView(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public ActivationFocusRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public ActivationFocusRecyclerView(
|
||||
@NonNull Context context,
|
||||
@Nullable AttributeSet attrs,
|
||||
int defStyleAttr
|
||||
) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.pukka.mediaapp.widget
|
||||
|
||||
data class ActivationKeyItem(
|
||||
val value: String
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.pukka.mediaapp.widget
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.pukka.mediaapp.R
|
||||
|
||||
class ActivationKeypadAdapter(
|
||||
private val keys: List<ActivationKeyItem>,
|
||||
private val onKeyClick: (String) -> Unit
|
||||
) : RecyclerView.Adapter<ActivationKeypadAdapter.KeyViewHolder>() {
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KeyViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_activation_key, parent, false)
|
||||
return KeyViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: KeyViewHolder, position: Int) {
|
||||
val item = keys[position]
|
||||
holder.keyView.text = item.value
|
||||
holder.itemView.setOnClickListener {
|
||||
onKeyClick(item.value)
|
||||
}
|
||||
holder.itemView.setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action != KeyEvent.ACTION_DOWN) {
|
||||
return@setOnKeyListener false
|
||||
}
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
|
||||
onKeyClick(item.value)
|
||||
return@setOnKeyListener true
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return keys.size
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return keys[position].value.hashCode().toLong()
|
||||
}
|
||||
|
||||
class KeyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val keyView: TextView = itemView.findViewById(R.id.id_tv_key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
package com.pukka.mediaapp.widget
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.pukka.mediaapp.BuildConfig
|
||||
import com.pukka.mediaapp.R
|
||||
import com.pukka.mediaapp.config.AppConfiguration
|
||||
import com.pukka.mediaapp.utils.DeviceInfoUtil
|
||||
|
||||
class DeviceNotActivatedView @JvmOverloads constructor(
|
||||
private val mContext: Context?,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : RelativeLayout(
|
||||
mContext, attrs, defStyleAttr
|
||||
) {
|
||||
private val viewTag = "DeviceNotActivatedView"
|
||||
private var currentInputIndex = INDEX_LOCATION_INPUT
|
||||
private var pendingFocusInputIndex: Int? = null
|
||||
private var mUnifiedInputView: LocationCodeInputView? = null
|
||||
private var mMainMotionLayout: MotionLayout? = null
|
||||
private var mInputDisplayContainer: ViewGroup? = null
|
||||
private var mLocationPreviewView: TextView? = null
|
||||
private var mLocationTipsView: TextView? = null
|
||||
private var mActivatePreviewView: TextView? = null
|
||||
private var mActivateBgView : View? = null
|
||||
private var mActivateTipsView: TextView? = null
|
||||
private var mActivatePreSuffixView: TextView? = null
|
||||
private var mActivationInputContainer: ViewGroup? = null
|
||||
private var mRvDeviceInfo: RecyclerView? = null
|
||||
private var mDeviceInfoAdapter: DeviceInfoAdapter? = null
|
||||
private var mOnActivateListener: OnActivateListener? = null
|
||||
private var mBackButton: LinearLayout? = null
|
||||
private var mActivatePlaceholderError = false
|
||||
private var mLastActivationEditableLength = 0
|
||||
private var mLastLocationEditableLength = 0
|
||||
private var suppressNextLocationAutoSwitch = false
|
||||
private var mCurrentErrorCode: Int? = null
|
||||
private var isActivationMotionEnabled = false
|
||||
private var isKeyboardInputInteractive = false
|
||||
|
||||
interface OnActivateListener {
|
||||
fun onSubmit(activateCode: String, roomNo: String)
|
||||
}
|
||||
|
||||
init {
|
||||
initView(mContext)
|
||||
}
|
||||
|
||||
private fun initView(context: Context?) {
|
||||
LayoutInflater.from(context).inflate(R.layout.device_not_activated_view, this, true)
|
||||
mMainMotionLayout = findViewById(R.id.id_ml_main_panel)
|
||||
mActivationInputContainer = findViewById(R.id.id_cl_activation_input_container)
|
||||
mInputDisplayContainer = findViewById(R.id.id_cl_input_display)
|
||||
mLocationPreviewView = findViewById(R.id.id_tv_location_placeholder)
|
||||
mLocationTipsView = findViewById(R.id.id_tv_location_tips)
|
||||
mActivateTipsView = findViewById(R.id.id_tv_activate_tips)
|
||||
mActivatePreSuffixView = findViewById(R.id.id_tv_activate_pre)
|
||||
mActivateBgView = findViewById(R.id.id_v_activate_code_bg)
|
||||
mActivatePreviewView = findViewById(R.id.id_tv_activate_placeholder)
|
||||
mUnifiedInputView = findViewById(R.id.id_v_location_input_panel)
|
||||
mRvDeviceInfo = findViewById<RecyclerView>(R.id.id_rv_device_info)
|
||||
initInputPanels()
|
||||
initDeviceInfo()
|
||||
initLocationCodeInput()
|
||||
initActivationCodeInput()
|
||||
initInputDisplayMotion()
|
||||
initBackButton()
|
||||
initInputTouchGuard()
|
||||
|
||||
// daycard 无渠道前缀,隐藏7位码设计的"AB-"前缀
|
||||
mActivatePreSuffixView?.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun initInputTouchGuard() {
|
||||
mActivationInputContainer?.setOnTouchListener { _, _ ->
|
||||
!isKeyboardInputInteractive
|
||||
}
|
||||
mUnifiedInputView?.setOnTouchListener { _, _ ->
|
||||
!isKeyboardInputInteractive
|
||||
}
|
||||
}
|
||||
|
||||
private fun initInputPanels() {
|
||||
mUnifiedInputView?.setInputMode(LocationCodeInputView.InputMode.LOCATION)
|
||||
mUnifiedInputView?.setOnNavigateUpToTabListener {
|
||||
focusInputPreview(currentInputIndex)
|
||||
}
|
||||
mUnifiedInputView?.setOnNavigateLeftFallbackListener {
|
||||
focusInputPreview(currentInputIndex)
|
||||
}
|
||||
selectInputPage(INDEX_LOCATION_INPUT)
|
||||
}
|
||||
|
||||
private fun initInputDisplayMotion() {
|
||||
syncInputPanelEnabledState(false)
|
||||
syncLocationPlaceholder("")
|
||||
showLocationDefaultTips()
|
||||
syncActivationPlaceholder("")
|
||||
updateTabDisplayState()
|
||||
applyActivationMotionToggle()
|
||||
mInputDisplayContainer?.setOnFocusChangeListener { _, hasFocus ->
|
||||
logDetail("inputDisplay focus changed, hasFocus=$hasFocus, currentState=${mMainMotionLayout?.currentState}")
|
||||
if (hasFocus) {
|
||||
return@setOnFocusChangeListener
|
||||
}
|
||||
post {
|
||||
if (!isFocusInInputPanel()) {
|
||||
logDetail("inputDisplay lost focus and focus not in input panel -> collapseInputPanel")
|
||||
collapseInputPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
mInputDisplayContainer?.setOnClickListener {
|
||||
enterInputInteraction(INDEX_LOCATION_INPUT)
|
||||
}
|
||||
mLocationPreviewView?.setOnClickListener {
|
||||
enterInputInteraction(INDEX_LOCATION_INPUT)
|
||||
}
|
||||
mActivateBgView?.setOnClickListener {
|
||||
enterInputInteraction(INDEX_ACTIVATE_INPUT)
|
||||
}
|
||||
mLocationPreviewView?.setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action != KeyEvent.ACTION_DOWN) {
|
||||
return@setOnKeyListener false
|
||||
}
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
enterInputInteraction(INDEX_LOCATION_INPUT)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
mActivateBgView?.setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action != KeyEvent.ACTION_DOWN) {
|
||||
return@setOnKeyListener false
|
||||
}
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
enterInputInteraction(INDEX_ACTIVATE_INPUT)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
mInputDisplayContainer?.setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action != KeyEvent.ACTION_DOWN) {
|
||||
return@setOnKeyListener false
|
||||
}
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER,
|
||||
KeyEvent.KEYCODE_NUMPAD_ENTER -> {
|
||||
enterInputInteraction(currentInputIndex)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
mMainMotionLayout?.setTransitionListener(object : MotionLayout.TransitionListener {
|
||||
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int) = Unit
|
||||
|
||||
override fun onTransitionChange(
|
||||
motionLayout: MotionLayout?,
|
||||
startId: Int,
|
||||
endId: Int,
|
||||
progress: Float
|
||||
) = Unit
|
||||
|
||||
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
|
||||
logDetail(
|
||||
"motion transition completed, currentId=$currentId, pendingFocusInputIndex=$pendingFocusInputIndex, currentInputIndex=$currentInputIndex"
|
||||
)
|
||||
syncInputPanelEnabledState(currentId == R.id.state_input_focused)
|
||||
if (currentId == R.id.state_input_focused) {
|
||||
dispatchPendingInputFocusIfNeeded()
|
||||
} else {
|
||||
pendingFocusInputIndex = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransitionTrigger(
|
||||
motionLayout: MotionLayout?,
|
||||
triggerId: Int,
|
||||
positive: Boolean,
|
||||
progress: Float
|
||||
) = Unit
|
||||
})
|
||||
}
|
||||
|
||||
private fun applyActivationMotionToggle() {
|
||||
mMainMotionLayout?.setInteractionEnabled(isActivationMotionEnabled)
|
||||
if (isActivationMotionEnabled) {
|
||||
syncInputPanelEnabledState(mMainMotionLayout?.currentState == R.id.state_input_focused)
|
||||
return
|
||||
}
|
||||
pendingFocusInputIndex = null
|
||||
if (mMainMotionLayout?.currentState != R.id.state_input_focused) {
|
||||
mMainMotionLayout?.transitionToState(R.id.state_input_focused)
|
||||
}
|
||||
syncInputPanelEnabledState(true)
|
||||
dispatchInputPageFocus(currentInputIndex, 0)
|
||||
}
|
||||
|
||||
private fun selectInputPage(position: Int) {
|
||||
if (position !in INDEX_LOCATION_INPUT..INDEX_ACTIVATE_INPUT) return
|
||||
currentInputIndex = position
|
||||
val mode = if (position == INDEX_LOCATION_INPUT) {
|
||||
LocationCodeInputView.InputMode.LOCATION
|
||||
} else {
|
||||
LocationCodeInputView.InputMode.ACTIVATION
|
||||
}
|
||||
if (mode == LocationCodeInputView.InputMode.LOCATION
|
||||
&& mUnifiedInputView?.getInputMode() != LocationCodeInputView.InputMode.LOCATION
|
||||
) {
|
||||
suppressNextLocationAutoSwitch = true
|
||||
}
|
||||
mUnifiedInputView?.setInputMode(mode)
|
||||
updateTabDisplayState()
|
||||
}
|
||||
|
||||
private fun focusInputPreview(position: Int) {
|
||||
val target = when (position) {
|
||||
INDEX_LOCATION_INPUT -> mLocationPreviewView
|
||||
INDEX_ACTIVATE_INPUT -> mActivateBgView
|
||||
else -> null
|
||||
}
|
||||
if (target != null && target.isShown && target.isEnabled) {
|
||||
target.requestFocus()
|
||||
return
|
||||
}
|
||||
mInputDisplayContainer?.requestFocus()
|
||||
}
|
||||
|
||||
private fun enterInputInteraction(position: Int) {
|
||||
if (position !in INDEX_LOCATION_INPUT..INDEX_ACTIVATE_INPUT) {
|
||||
return
|
||||
}
|
||||
logDetail("enterInputInteraction position=$position, currentState=${mMainMotionLayout?.currentState}")
|
||||
pendingFocusInputIndex = position
|
||||
selectInputPage(position)
|
||||
if (!isActivationMotionEnabled) {
|
||||
dispatchInputPageFocus(position, 0)
|
||||
return
|
||||
}
|
||||
expandInputPanel(false)
|
||||
dispatchPendingInputFocusIfNeeded()
|
||||
}
|
||||
|
||||
private fun dispatchPendingInputFocusIfNeeded() {
|
||||
if (mMainMotionLayout?.currentState != R.id.state_input_focused) {
|
||||
logDetail("dispatchPendingInputFocusIfNeeded skipped, state=${mMainMotionLayout?.currentState}, pending=$pendingFocusInputIndex")
|
||||
return
|
||||
}
|
||||
val focusTarget = pendingFocusInputIndex ?: return
|
||||
logDetail("dispatchPendingInputFocusIfNeeded target=$focusTarget")
|
||||
pendingFocusInputIndex = null
|
||||
dispatchInputPageFocus(focusTarget, 0)
|
||||
}
|
||||
|
||||
private fun dispatchInputPageFocus(position: Int, attempt: Int) {
|
||||
mActivationInputContainer?.post {
|
||||
logDetail("dispatchInputPageFocus position=$position, attempt=$attempt")
|
||||
if (requestInputPageChildFocus(position)) {
|
||||
logDetail("dispatchInputPageFocus success at attempt=$attempt")
|
||||
return@post
|
||||
}
|
||||
if (attempt >= INPUT_FOCUS_RETRY_COUNT) {
|
||||
logDetail("dispatchInputPageFocus failed after max attempts, position=$position")
|
||||
return@post
|
||||
}
|
||||
mActivationInputContainer?.postDelayed(
|
||||
{ dispatchInputPageFocus(position, attempt + 1) },
|
||||
INPUT_FOCUS_RETRY_DELAY_MS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestInputPageChildFocus(position: Int): Boolean {
|
||||
if (position !in INDEX_LOCATION_INPUT..INDEX_ACTIVATE_INPUT) {
|
||||
return false
|
||||
}
|
||||
if (mUnifiedInputView?.requestLeftEntryDefaultFocus() == true) {
|
||||
return true
|
||||
}
|
||||
return requestFocusFromCandidates(
|
||||
listOf(
|
||||
mUnifiedInputView?.findViewById(R.id.id_rv_location_keypad),
|
||||
mUnifiedInputView?.findViewById(R.id.id_tv_location_delete),
|
||||
mUnifiedInputView?.findViewById(R.id.id_tv_location_clear)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestFocusFromCandidates(candidates: List<View?>): Boolean {
|
||||
candidates.forEach { candidate ->
|
||||
if (requestFocusOnCandidate(candidate)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun requestFocusOnCandidate(target: View?): Boolean {
|
||||
if (target == null || !target.isEnabled || !target.isShown) {
|
||||
return false
|
||||
}
|
||||
val hasFocused = target.requestFocus() || target.hasFocus()
|
||||
if (hasFocused) {
|
||||
return true
|
||||
}
|
||||
val focusedDescendant = target.findFocus()
|
||||
return focusedDescendant != null && focusedDescendant.isShown
|
||||
}
|
||||
|
||||
private fun expandInputPanel(requestInputFocus: Boolean) {
|
||||
if (!isActivationMotionEnabled) {
|
||||
syncInputPanelEnabledState(true)
|
||||
if (requestInputFocus) {
|
||||
dispatchInputPageFocus(currentInputIndex, 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
logDetail("expandInputPanel requestInputFocus=$requestInputFocus, currentState=${mMainMotionLayout?.currentState}")
|
||||
syncInputPanelEnabledState(true)
|
||||
if (mMainMotionLayout?.currentState != R.id.state_input_focused) {
|
||||
mMainMotionLayout?.transitionToEnd()
|
||||
}
|
||||
if (requestInputFocus) {
|
||||
post { enterInputInteraction(currentInputIndex) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun collapseInputPanel() {
|
||||
if (!isActivationMotionEnabled) {
|
||||
syncInputPanelEnabledState(true)
|
||||
return
|
||||
}
|
||||
logDetail("collapseInputPanel currentState=${mMainMotionLayout?.currentState}")
|
||||
if (mMainMotionLayout?.currentState != R.id.state_default) {
|
||||
mMainMotionLayout?.transitionToStart()
|
||||
} else {
|
||||
syncInputPanelEnabledState(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncInputPanelEnabledState(enabled: Boolean) {
|
||||
logDetail("syncInputPanelEnabledState enabled=$enabled")
|
||||
mActivationInputContainer?.isEnabled = enabled
|
||||
mUnifiedInputView?.isEnabled = enabled
|
||||
isKeyboardInputInteractive = enabled
|
||||
mActivationInputContainer?.descendantFocusability = if (enabled) {
|
||||
ViewGroup.FOCUS_AFTER_DESCENDANTS
|
||||
} else {
|
||||
ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||
}
|
||||
if (!enabled) {
|
||||
mActivationInputContainer?.clearFocus()
|
||||
mUnifiedInputView?.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isFocusInInputPanel(): Boolean {
|
||||
val focusedView = findFocus() ?: return false
|
||||
return focusedView.isDescendantOf(mInputDisplayContainer)
|
||||
|| focusedView.isDescendantOf(mActivationInputContainer)
|
||||
}
|
||||
|
||||
private fun View.isDescendantOf(parentView: View?): Boolean {
|
||||
if (parentView == null) {
|
||||
return false
|
||||
}
|
||||
var current: View? = this
|
||||
while (current != null) {
|
||||
if (current === parentView) {
|
||||
return true
|
||||
}
|
||||
current = current.parent as? View
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun updateTabDisplayState() {
|
||||
}
|
||||
|
||||
private fun initActivationCodeInput() {
|
||||
mUnifiedInputView?.setOnActivationChangedListener { activationCode ->
|
||||
val currentLength = activationCode.length
|
||||
val hasLengthChanged = currentLength != mLastActivationEditableLength
|
||||
logDetail(
|
||||
"onActivationChanged codeLength=$currentLength, beforeClearError=$mActivatePlaceholderError, hasLengthChanged=$hasLengthChanged"
|
||||
)
|
||||
if (hasLengthChanged) {
|
||||
clearActivatePlaceholderErrorState()
|
||||
} else {
|
||||
logDetail("onActivationChanged ignore clear error because length unchanged")
|
||||
}
|
||||
mLastActivationEditableLength = currentLength
|
||||
syncActivationPlaceholder(activationCode)
|
||||
}
|
||||
mUnifiedInputView?.setOnTipChangedListener { message, color ->
|
||||
logColorDecision(
|
||||
source = "onTipChanged.activateTips",
|
||||
view = mActivateTipsView,
|
||||
targetColor = color,
|
||||
reason = "tip message changed: $message"
|
||||
)
|
||||
mActivateTipsView?.setTextColor(color)
|
||||
mActivateTipsView?.text = message
|
||||
}
|
||||
mUnifiedInputView?.setOnSubmitListener { activateCode ->
|
||||
val roomNo = mUnifiedInputView?.getLocationCode().orEmpty()
|
||||
if (mOnActivateListener == null) {
|
||||
mUnifiedInputView?.setSubmitting(false)
|
||||
showActivateErrorMessage(context.getString(R.string.str_activate_submit_unavailable))
|
||||
return@setOnSubmitListener
|
||||
}
|
||||
mOnActivateListener?.onSubmit(activateCode, roomNo)
|
||||
}
|
||||
mUnifiedInputView?.showDefaultTips()
|
||||
}
|
||||
|
||||
private fun initLocationCodeInput() {
|
||||
mUnifiedInputView?.setOnLocationChangedListener { locationCode ->
|
||||
val currentLength = locationCode.length
|
||||
val isLengthGrowToMax = currentLength == LOCATION_CODE_MAX_LENGTH
|
||||
&& mLastLocationEditableLength < LOCATION_CODE_MAX_LENGTH
|
||||
syncLocationPlaceholder(locationCode)
|
||||
showLocationDefaultTips()
|
||||
if (suppressNextLocationAutoSwitch) {
|
||||
logDetail("skip location auto switch once, locationCode=$locationCode")
|
||||
suppressNextLocationAutoSwitch = false
|
||||
} else if (isLengthGrowToMax) {
|
||||
enterInputInteraction(INDEX_ACTIVATE_INPUT)
|
||||
}
|
||||
mLastLocationEditableLength = currentLength
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncLocationPlaceholder(value: String) {
|
||||
val placeholder = context.getString(R.string.str_location_placeholder)
|
||||
applyPlaceholderTextState(mLocationPreviewView, value, placeholder)
|
||||
}
|
||||
|
||||
private fun syncActivationPlaceholder(value: String) {
|
||||
val placeholder = context.getString(R.string.str_activate_placeholder)
|
||||
logDetail("syncActivationPlaceholder valueBlank=${value.isBlank()}, errorState=$mActivatePlaceholderError")
|
||||
applyActivatePlaceholderTextState(value, placeholder)
|
||||
}
|
||||
|
||||
private fun showLocationDefaultTips() {
|
||||
mLocationTipsView?.setTextColor(ContextCompat.getColor(context, R.color.activation_text_secondary_new))
|
||||
mLocationTipsView?.text = context.getString(R.string.str_location_input_hint)
|
||||
}
|
||||
|
||||
private fun applyPlaceholderTextState(targetView: TextView?, value: String, placeholder: String) {
|
||||
if (targetView == null) {
|
||||
return
|
||||
}
|
||||
if (value.isBlank()) {
|
||||
targetView.text = placeholder
|
||||
val hintColor = ContextCompat.getColor(context, R.color.activation_text_hint)
|
||||
logColorDecision(
|
||||
source = "applyPlaceholderTextState.hint",
|
||||
view = targetView,
|
||||
targetColor = hintColor,
|
||||
reason = "placeholder shown"
|
||||
)
|
||||
targetView.setTextColor(hintColor)
|
||||
return
|
||||
}
|
||||
targetView.text = value
|
||||
val normalColor = ContextCompat.getColor(context, R.color.activation_key_text_selector)
|
||||
logColorDecision(
|
||||
source = "applyPlaceholderTextState.value",
|
||||
view = targetView,
|
||||
targetColor = normalColor,
|
||||
reason = "value shown"
|
||||
)
|
||||
targetView.setTextColor(normalColor)
|
||||
}
|
||||
|
||||
private fun applyActivatePlaceholderTextState(value: String, placeholder: String) {
|
||||
val targetView = mActivatePreviewView ?: return
|
||||
if (!mActivatePlaceholderError) {
|
||||
applyPlaceholderTextState(targetView, value, placeholder)
|
||||
return
|
||||
}
|
||||
targetView.text = if (value.isBlank()) placeholder else value
|
||||
val textColorResId = R.color.error_tip_red
|
||||
val targetColor = ContextCompat.getColor(context, textColorResId)
|
||||
logColorDecision(
|
||||
source = "applyActivatePlaceholderTextState",
|
||||
view = targetView,
|
||||
targetColor = targetColor,
|
||||
reason = "valueBlank=${value.isBlank()}, errorState=$mActivatePlaceholderError, colorRes=$textColorResId"
|
||||
)
|
||||
targetView.setTextColor(targetColor)
|
||||
}
|
||||
|
||||
private fun clearActivatePlaceholderErrorState() {
|
||||
logDetail("clearActivatePlaceholderErrorState from=$mActivatePlaceholderError to=false")
|
||||
mActivatePlaceholderError = false
|
||||
}
|
||||
|
||||
private fun showActivatePlaceholderErrorState() {
|
||||
logDetail("showActivatePlaceholderErrorState from=$mActivatePlaceholderError to=true")
|
||||
mActivatePlaceholderError = true
|
||||
syncActivationPlaceholder(mUnifiedInputView?.getActivationEditablePart().orEmpty())
|
||||
val shakeAnimation = AnimationUtils.loadAnimation(context, R.anim.activation_code_shake)
|
||||
logDetail("shakeAnimation prepared=${shakeAnimation != null}, startAnimationDisabled=true")
|
||||
mActivatePreviewView?.clearAnimation()
|
||||
mActivatePreviewView?.startAnimation(shakeAnimation)
|
||||
}
|
||||
|
||||
private fun logColorDecision(source: String, view: TextView?, targetColor: Int, reason: String) {
|
||||
logDetail(
|
||||
"colorDecision source=$source, viewId=${view?.id}, viewHash=${view?.hashCode()}, currentColor=${view?.currentTextColor}, targetColor=$targetColor, reason=$reason"
|
||||
)
|
||||
}
|
||||
|
||||
private fun logDetail(message: String) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(viewTag, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initDeviceInfo() {
|
||||
val mac = DeviceInfoUtil.getDeviceIdentity(mContext!!).mac ?: ""
|
||||
val channel = AppConfiguration.getInstance().channelId.uppercase()
|
||||
val deviceInfoItems = listOf(
|
||||
// DeviceInfoItem(context.getString(R.string.str_device_name), Build.MODEL)
|
||||
DeviceInfoItem(context.getString(R.string.str_mac_address), mac.uppercase()),
|
||||
DeviceInfoItem(context.getString(R.string.str_device_id), AppConfiguration.getInstance().getmDeviceUid()),
|
||||
DeviceInfoItem(context.getString(R.string.str_version_no), BuildConfig.VERSION_NAME),
|
||||
DeviceInfoItem(context.getString(R.string.str_channel_id), channel),
|
||||
)
|
||||
mRvDeviceInfo?.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
|
||||
mRvDeviceInfo?.setHasFixedSize(true)
|
||||
mRvDeviceInfo?.itemAnimator = null
|
||||
mDeviceInfoAdapter = DeviceInfoAdapter(deviceInfoItems)
|
||||
mRvDeviceInfo?.adapter = mDeviceInfoAdapter
|
||||
}
|
||||
|
||||
fun setOnActivateListener(listener: OnActivateListener?) {
|
||||
mOnActivateListener = listener
|
||||
}
|
||||
|
||||
fun setSubmitting(submitting: Boolean) {
|
||||
mUnifiedInputView?.setSubmitting(submitting)
|
||||
}
|
||||
|
||||
fun setActivationLoadingVisible(visible: Boolean) {
|
||||
mUnifiedInputView?.setSubmitting(visible)
|
||||
mInputDisplayContainer?.isEnabled = !visible
|
||||
mInputDisplayContainer?.isClickable = !visible
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
clearActivatePlaceholderErrorState()
|
||||
}
|
||||
|
||||
fun showActivateMessage(message: String) {
|
||||
mUnifiedInputView?.showActivateMessage(message)
|
||||
}
|
||||
|
||||
fun showActivateErrorMessage(message: String) {
|
||||
if (shouldHighlightActivatePlaceholder()) {
|
||||
showActivatePlaceholderErrorState()
|
||||
} else {
|
||||
clearActivatePlaceholderErrorState()
|
||||
syncActivationPlaceholder(mUnifiedInputView?.getActivationEditablePart().orEmpty())
|
||||
}
|
||||
mUnifiedInputView?.showActivateErrorMessage(message)
|
||||
}
|
||||
|
||||
fun switchToActivateInputPage() {
|
||||
enterInputInteraction(INDEX_ACTIVATE_INPUT)
|
||||
}
|
||||
|
||||
fun setActivationMotionEnabled(enabled: Boolean) {
|
||||
if (isActivationMotionEnabled == enabled) {
|
||||
return
|
||||
}
|
||||
isActivationMotionEnabled = enabled
|
||||
applyActivationMotionToggle()
|
||||
}
|
||||
|
||||
fun setErrorInfo(errorInfo: String) {
|
||||
updateStatusCode(errorInfo)
|
||||
mUnifiedInputView?.showDefaultTips()
|
||||
showLocationDefaultTips()
|
||||
this.visibility = VISIBLE
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
fun showDefaultTips(){
|
||||
mUnifiedInputView?.showDefaultTips()
|
||||
showLocationDefaultTips()
|
||||
}
|
||||
|
||||
fun updateErrorInfo(errorCode: Int) {
|
||||
updateStatusCode(errorCode.toString())
|
||||
}
|
||||
|
||||
fun updateStatusCode(statusCode: String) {
|
||||
mCurrentErrorCode = statusCode.toIntOrNull()
|
||||
logDetail("updateStatusCode statusCode=$statusCode, parsedErrorCode=$mCurrentErrorCode")
|
||||
mDeviceInfoAdapter?.updateStatusCode(statusCode)
|
||||
}
|
||||
|
||||
private fun shouldHighlightActivatePlaceholder(): Boolean {
|
||||
return mCurrentErrorCode == ACTIVATE_PLACEHOLDER_HIGHLIGHT_ERROR_CODE
|
||||
}
|
||||
|
||||
private fun initBackButton() {
|
||||
if (AppConfiguration.getInstance().isTouchMode) {
|
||||
if (mBackButton == null) {
|
||||
mBackButton = LinearLayout(context).apply {
|
||||
id = R.id.id_back_button_floating
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = android.view.Gravity.CENTER
|
||||
isFocusable = true
|
||||
isClickable = true
|
||||
background = ContextCompat.getDrawable(context, R.drawable.bg_search_back_btn_selector)
|
||||
val buttonSize = resources.getDimensionPixelSize(R.dimen.qb_px_80)
|
||||
val marginEnd = resources.getDimensionPixelSize(R.dimen.qb_px_40)
|
||||
val marginBottom = resources.getDimensionPixelSize(R.dimen.qb_px_40)
|
||||
val layoutParams = LayoutParams(buttonSize, buttonSize).apply {
|
||||
addRule(ALIGN_PARENT_BOTTOM)
|
||||
addRule(ALIGN_PARENT_RIGHT)
|
||||
setMargins(0, 0, marginEnd, marginBottom)
|
||||
}
|
||||
this.layoutParams = layoutParams
|
||||
val iconSize = resources.getDimensionPixelSize(R.dimen.qb_px_28)
|
||||
val iconView = ImageView(context).apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(iconSize, iconSize)
|
||||
setImageResource(R.drawable.icon_detail_back_normal)
|
||||
}
|
||||
addView(iconView)
|
||||
setOnClickListener {
|
||||
(context as? Activity)?.onBackPressed()
|
||||
}
|
||||
}
|
||||
addView(mBackButton)
|
||||
}
|
||||
mBackButton?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private data class DeviceInfoItem(val label: String, val value: String)
|
||||
|
||||
private class DeviceInfoAdapter(
|
||||
private val items: List<DeviceInfoItem>
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private var statusCode = ""
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == VIEW_TYPE_INFO) {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_device_info, parent, false)
|
||||
DeviceInfoViewHolder(view)
|
||||
} else {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_device_info_footer, parent, false)
|
||||
StatusFooterViewHolder(view)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
if (holder is DeviceInfoViewHolder && position < items.size) {
|
||||
val item = items[position]
|
||||
holder.labelView.text = item.label
|
||||
holder.valueView.text = item.value
|
||||
} else if (holder is StatusFooterViewHolder) {
|
||||
holder.footerView.text = if (statusCode.isEmpty()) "状态码:" else "状态码:$statusCode"
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return items.size + 1
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (position < items.size) VIEW_TYPE_INFO else VIEW_TYPE_FOOTER
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return if (position < items.size) items[position].label.hashCode().toLong() else Long.MAX_VALUE
|
||||
}
|
||||
|
||||
fun updateStatusCode(value: String) {
|
||||
statusCode = value
|
||||
notifyItemChanged(items.size)
|
||||
}
|
||||
|
||||
class DeviceInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val labelView: TextView = itemView.findViewById(R.id.id_tv_info_label)
|
||||
val valueView: TextView = itemView.findViewById(R.id.id_tv_info_value)
|
||||
}
|
||||
|
||||
class StatusFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val footerView: TextView = itemView.findViewById(R.id.id_tv_status_footer)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_INFO = 0
|
||||
private const val VIEW_TYPE_FOOTER = 1
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_ACTIVATION_MOTION_ENABLED = false
|
||||
private const val ACTIVATE_PLACEHOLDER_HIGHLIGHT_ERROR_CODE = 10500
|
||||
private const val LOCATION_CODE_MAX_LENGTH = 5
|
||||
private const val INDEX_LOCATION_INPUT = 0
|
||||
private const val INDEX_ACTIVATE_INPUT = 1
|
||||
private const val INPUT_FOCUS_RETRY_COUNT = 8
|
||||
private const val INPUT_FOCUS_RETRY_DELAY_MS = 50L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @file LocationCodeInputView.kt
|
||||
* @description 位置编号输入独立视图,复用激活码输入交互模型并支持全量字母数字键盘
|
||||
* @author ainetv
|
||||
* @date 2026-03-30
|
||||
*/
|
||||
package com.pukka.mediaapp.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.pukka.mediaapp.R
|
||||
import com.pukka.mediaapp.config.AppConfiguration
|
||||
|
||||
open class LocationCodeInputView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
enum class InputMode {
|
||||
LOCATION,
|
||||
ACTIVATION
|
||||
}
|
||||
|
||||
private val defaultActivationTips = context.getString(R.string.str_activate_input_hint)
|
||||
private val channelPrefix = AppConfiguration.getInstance().channelId.uppercase().take(2)
|
||||
private val maxEditableLength = 5
|
||||
private val keypadSpanCount = 6
|
||||
|
||||
private var mRvKeypad: ActivationFocusRecyclerView? = null
|
||||
private var mTvDelete: TextView? = null
|
||||
private var mTvClear: TextView? = null
|
||||
|
||||
private var mCurrentMode: InputMode = InputMode.LOCATION
|
||||
private var mLocationCode = ""
|
||||
private var mActivationEditablePart = ""
|
||||
private var mLastSubmittedActivationCode = ""
|
||||
private var mSubmitting = false
|
||||
|
||||
private var mOnModeInputChangedListener: ((InputMode, String) -> Unit)? = null
|
||||
private var mOnLocationChangedListener: ((String) -> Unit)? = null
|
||||
private var mOnActivationChangedListener: ((String) -> Unit)? = null
|
||||
private var mOnSubmitListener: ((String) -> Unit)? = null
|
||||
private var mOnTipChangedListener: ((String, Int) -> Unit)? = null
|
||||
private var mOnNavigateUpToTabListener: (() -> Unit)? = null
|
||||
private var mOnNavigateLeftFallbackListener: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_location_code_input, this, true)
|
||||
mRvKeypad = findViewById(R.id.id_rv_location_keypad)
|
||||
mTvDelete = findViewById(R.id.id_tv_location_delete)
|
||||
mTvClear = findViewById(R.id.id_tv_location_clear)
|
||||
initCodeAndAction()
|
||||
initKeypad()
|
||||
}
|
||||
|
||||
private fun initCodeAndAction() {
|
||||
mTvDelete?.setOnClickListener { deleteLastChar() }
|
||||
mTvClear?.setOnClickListener { clearAll() }
|
||||
val actionKeyListener = View.OnKeyListener { targetView, keyCode, event ->
|
||||
if (event.action != KeyEvent.ACTION_DOWN) {
|
||||
return@OnKeyListener false
|
||||
}
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
|
||||
requestFocusToNearestKeypadChild(targetView)
|
||||
return@OnKeyListener true
|
||||
}
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && targetView === mTvDelete) {
|
||||
return@OnKeyListener dispatchLeftFallback()
|
||||
}
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
|
||||
return@OnKeyListener false
|
||||
}
|
||||
false
|
||||
}
|
||||
mTvDelete?.setOnKeyListener(actionKeyListener)
|
||||
mTvClear?.setOnKeyListener(actionKeyListener)
|
||||
}
|
||||
|
||||
private fun initKeypad() {
|
||||
val layoutManager = GridLayoutManager(context, keypadSpanCount)
|
||||
val spacing = resources.getDimensionPixelSize(R.dimen.qb_px_10)
|
||||
mRvKeypad?.layoutManager = layoutManager
|
||||
mRvKeypad?.setItemAnimator(null)
|
||||
// mRvKeypad?.setPadding(0, 0, 0, spacing)
|
||||
if (mRvKeypad?.itemDecorationCount == 0) {
|
||||
mRvKeypad?.addItemDecoration(RightEdgeAlignedGridDecoration(keypadSpanCount, spacing))
|
||||
}
|
||||
mRvKeypad?.setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action != KeyEvent.ACTION_DOWN) {
|
||||
return@setOnKeyListener false
|
||||
}
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
|
||||
val focus = mRvKeypad?.focusedChild ?: return@setOnKeyListener false
|
||||
val position = mRvKeypad?.getChildAdapterPosition(focus) ?: return@setOnKeyListener false
|
||||
if (position in 0..5) {
|
||||
mTvDelete?.requestFocus()
|
||||
return@setOnKeyListener true
|
||||
}
|
||||
}
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
|
||||
val focus = mRvKeypad?.focusedChild ?: return@setOnKeyListener false
|
||||
val position = mRvKeypad?.getChildAdapterPosition(focus) ?: return@setOnKeyListener false
|
||||
if (position % keypadSpanCount == 0) {
|
||||
return@setOnKeyListener dispatchLeftFallback()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
applyModeConfiguration(InputMode.LOCATION, emitInputChanged = false)
|
||||
}
|
||||
|
||||
private fun applyModeConfiguration(mode: InputMode, emitInputChanged: Boolean) {
|
||||
mCurrentMode = mode
|
||||
val keys = when (mode) {
|
||||
InputMode.LOCATION -> buildLocationKeys()
|
||||
InputMode.ACTIVATION -> buildActivationKeys()
|
||||
}
|
||||
mRvKeypad?.adapter = ActivationKeypadAdapter(keys) { value ->
|
||||
appendChar(value)
|
||||
}
|
||||
if (mode == InputMode.ACTIVATION) {
|
||||
showActivateTip(defaultActivationTips)
|
||||
}
|
||||
if (emitInputChanged) {
|
||||
notifyInputChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun setInputMode(mode: InputMode) {
|
||||
if (mCurrentMode == mode) {
|
||||
notifyInputChanged()
|
||||
return
|
||||
}
|
||||
applyModeConfiguration(mode, emitInputChanged = true)
|
||||
}
|
||||
|
||||
fun getInputMode(): InputMode = mCurrentMode
|
||||
|
||||
private fun buildLocationKeys(): List<ActivationKeyItem> {
|
||||
val allChars = mutableListOf<String>()
|
||||
('0'..'9').forEach { allChars.add(it.toString()) }
|
||||
('A'..'Z').forEach { allChars.add(it.toString()) }
|
||||
return allChars.map { ActivationKeyItem(it) }
|
||||
}
|
||||
|
||||
private fun buildActivationKeys(): List<ActivationKeyItem> {
|
||||
return listOf(
|
||||
"A", "B", "C", "D", "E", "F",
|
||||
"G", "H", "J", "K", "M", "N",
|
||||
"P", "Q", "R", "S", "T", "U",
|
||||
"V", "W", "X", "Y", "2", "3",
|
||||
"4", "5", "6", "7", "8", "9"
|
||||
).map { ActivationKeyItem(it) }
|
||||
}
|
||||
|
||||
private fun appendChar(value: String) {
|
||||
if (mSubmitting || currentEditableValue().length >= maxEditableLength) {
|
||||
return
|
||||
}
|
||||
val updated = currentEditableValue() + value
|
||||
updateEditableValue(updated)
|
||||
if (mCurrentMode == InputMode.ACTIVATION) {
|
||||
mLastSubmittedActivationCode = ""
|
||||
showActivateTip(defaultActivationTips)
|
||||
if (updated.length == maxEditableLength) {
|
||||
submitActivateCode(autoSubmit = true)
|
||||
}
|
||||
}
|
||||
notifyInputChanged()
|
||||
}
|
||||
|
||||
private fun deleteLastChar() {
|
||||
val editableValue = currentEditableValue()
|
||||
if (mSubmitting || editableValue.isEmpty()) {
|
||||
return
|
||||
}
|
||||
updateEditableValue(editableValue.dropLast(1))
|
||||
if (mCurrentMode == InputMode.ACTIVATION) {
|
||||
mLastSubmittedActivationCode = ""
|
||||
showActivateTip(defaultActivationTips)
|
||||
}
|
||||
notifyInputChanged()
|
||||
}
|
||||
|
||||
private fun clearAll() {
|
||||
if (mSubmitting || currentEditableValue().isEmpty()) {
|
||||
return
|
||||
}
|
||||
updateEditableValue("")
|
||||
if (mCurrentMode == InputMode.ACTIVATION) {
|
||||
mLastSubmittedActivationCode = ""
|
||||
showActivateTip(defaultActivationTips)
|
||||
}
|
||||
notifyInputChanged()
|
||||
}
|
||||
|
||||
private fun currentEditableValue(): String {
|
||||
return if (mCurrentMode == InputMode.LOCATION) mLocationCode else mActivationEditablePart
|
||||
}
|
||||
|
||||
private fun updateEditableValue(value: String) {
|
||||
if (mCurrentMode == InputMode.LOCATION) {
|
||||
mLocationCode = value
|
||||
} else {
|
||||
mActivationEditablePart = value
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyInputChanged() {
|
||||
val value = currentEditableValue()
|
||||
mOnModeInputChangedListener?.invoke(mCurrentMode, value)
|
||||
if (mCurrentMode == InputMode.LOCATION) {
|
||||
mOnLocationChangedListener?.invoke(value)
|
||||
} else {
|
||||
mOnActivationChangedListener?.invoke(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnModeInputChangedListener(listener: ((InputMode, String) -> Unit)?) {
|
||||
mOnModeInputChangedListener = listener
|
||||
}
|
||||
|
||||
fun setOnLocationChangedListener(listener: ((String) -> Unit)?) {
|
||||
mOnLocationChangedListener = listener
|
||||
}
|
||||
|
||||
fun setOnActivationChangedListener(listener: ((String) -> Unit)?) {
|
||||
mOnActivationChangedListener = listener
|
||||
}
|
||||
|
||||
fun setOnSubmitListener(listener: ((String) -> Unit)?) {
|
||||
mOnSubmitListener = listener
|
||||
}
|
||||
|
||||
fun setOnTipChangedListener(listener: ((String, Int) -> Unit)?) {
|
||||
mOnTipChangedListener = listener
|
||||
}
|
||||
|
||||
fun setOnNavigateUpToTabListener(listener: (() -> Unit)?) {
|
||||
mOnNavigateUpToTabListener = listener
|
||||
}
|
||||
|
||||
fun setOnNavigateLeftFallbackListener(listener: (() -> Unit)?) {
|
||||
mOnNavigateLeftFallbackListener = listener
|
||||
}
|
||||
|
||||
fun getLocationCode(): String {
|
||||
return mLocationCode
|
||||
}
|
||||
|
||||
fun setLocationCode(value: String) {
|
||||
mLocationCode = value.uppercase().filter { (it in 'A'..'Z') || (it in '0'..'9') }.take(maxEditableLength)
|
||||
if (mCurrentMode == InputMode.LOCATION) {
|
||||
notifyInputChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun getActivationEditablePart(): String {
|
||||
return mActivationEditablePart
|
||||
}
|
||||
|
||||
fun setActivationEditablePart(value: String) {
|
||||
mActivationEditablePart = value.uppercase().filter { (it in 'A'..'Z') || (it in '0'..'9') }.take(maxEditableLength)
|
||||
if (mCurrentMode == InputMode.ACTIVATION) {
|
||||
notifyInputChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearInput() {
|
||||
if (mCurrentMode == InputMode.LOCATION) {
|
||||
mLocationCode = ""
|
||||
} else {
|
||||
mActivationEditablePart = ""
|
||||
}
|
||||
notifyInputChanged()
|
||||
}
|
||||
|
||||
fun setSubmitting(submitting: Boolean) {
|
||||
mSubmitting = submitting
|
||||
mRvKeypad?.isEnabled = !submitting
|
||||
mTvDelete?.isEnabled = !submitting
|
||||
mTvClear?.isEnabled = !submitting
|
||||
if (mCurrentMode == InputMode.ACTIVATION) {
|
||||
if (submitting) {
|
||||
showActivateTip(context.getString(R.string.str_activate_submitting))
|
||||
} else {
|
||||
showActivateTip(defaultActivationTips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestFocusOnFirstKey() {
|
||||
mRvKeypad?.postDelayed({ mRvKeypad?.requestFocus() }, 500)
|
||||
}
|
||||
|
||||
fun requestLeftEntryDefaultFocus(): Boolean {
|
||||
return requestFocusOnCandidate(mRvKeypad)
|
||||
}
|
||||
|
||||
private fun dispatchLeftFallback(): Boolean {
|
||||
val fallbackListener = mOnNavigateLeftFallbackListener ?: return false
|
||||
fallbackListener.invoke()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun requestFocusOnCandidate(target: View?): Boolean {
|
||||
if (target == null || !target.isShown || !target.isEnabled) {
|
||||
return false
|
||||
}
|
||||
if (target.requestFocus() || target.hasFocus()) {
|
||||
return true
|
||||
}
|
||||
val focusedDescendant = target.findFocus()
|
||||
return focusedDescendant != null && focusedDescendant.isShown
|
||||
}
|
||||
|
||||
private fun requestFocusToNearestKeypadChild(anchorView: View): Boolean {
|
||||
val recyclerView = mRvKeypad ?: return false
|
||||
if (!recyclerView.isShown || !recyclerView.isEnabled) {
|
||||
return false
|
||||
}
|
||||
val anchorCenter = IntArray(2)
|
||||
anchorView.getLocationOnScreen(anchorCenter)
|
||||
val anchorCenterX = anchorCenter[0] + anchorView.width / 2
|
||||
val anchorCenterY = anchorCenter[1] + anchorView.height / 2
|
||||
var nearestChild: View? = null
|
||||
var minDistance = Long.MAX_VALUE
|
||||
for (index in 0 until recyclerView.childCount) {
|
||||
val child = recyclerView.getChildAt(index) ?: continue
|
||||
if (!child.isShown || !child.isFocusable || !child.isEnabled) {
|
||||
continue
|
||||
}
|
||||
val childCenter = IntArray(2)
|
||||
child.getLocationOnScreen(childCenter)
|
||||
val childCenterX = childCenter[0] + child.width / 2
|
||||
val childCenterY = childCenter[1] + child.height / 2
|
||||
val dx = (childCenterX - anchorCenterX).toLong()
|
||||
val dy = (childCenterY - anchorCenterY).toLong()
|
||||
val distance = dx * dx + dy * dy
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance
|
||||
nearestChild = child
|
||||
}
|
||||
}
|
||||
return nearestChild?.requestFocus() == true
|
||||
}
|
||||
|
||||
fun submitActivateCodeManually() {
|
||||
submitActivateCode(autoSubmit = false)
|
||||
}
|
||||
|
||||
fun showActivateMessage(message: String) {
|
||||
showActivateTip(message)
|
||||
}
|
||||
|
||||
fun showActivateErrorMessage(message: String) {
|
||||
showActivateTip(message, R.color.error_tip_red)
|
||||
}
|
||||
|
||||
fun showDefaultTips() {
|
||||
showActivateTip(defaultActivationTips)
|
||||
}
|
||||
|
||||
private fun showActivateTip(message: String) {
|
||||
showActivateTip(message, R.color.activation_text_secondary_new)
|
||||
}
|
||||
|
||||
private fun showActivateTip(message: String, colorResId: Int) {
|
||||
val color = ContextCompat.getColor(context, colorResId)
|
||||
mOnTipChangedListener?.invoke(message, color)
|
||||
}
|
||||
|
||||
private fun submitActivateCode(autoSubmit: Boolean) {
|
||||
if (mSubmitting) {
|
||||
return
|
||||
}
|
||||
val inputCode = normalizeCode(channelPrefix + mActivationEditablePart)
|
||||
if (inputCode.length < maxEditableLength) {
|
||||
if (!autoSubmit) {
|
||||
showActivateErrorMessage(context.getString(R.string.str_activate_code_7_tip))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (autoSubmit && inputCode == mLastSubmittedActivationCode) {
|
||||
return
|
||||
}
|
||||
mLastSubmittedActivationCode = inputCode
|
||||
setSubmitting(true)
|
||||
if (mOnSubmitListener == null) {
|
||||
setSubmitting(false)
|
||||
showActivateErrorMessage(context.getString(R.string.str_activate_submit_unavailable))
|
||||
return
|
||||
}
|
||||
mOnSubmitListener?.invoke(inputCode)
|
||||
}
|
||||
|
||||
private fun normalizeCode(value: String): String {
|
||||
if (value.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
val upperValue = value.uppercase()
|
||||
val filtered = StringBuilder()
|
||||
upperValue.forEach { ch ->
|
||||
if ((ch in 'A'..'Z') || (ch in '0'..'9')) {
|
||||
filtered.append(ch)
|
||||
}
|
||||
}
|
||||
return if (filtered.length > maxEditableLength) filtered.substring(0, maxEditableLength) else filtered.toString()
|
||||
}
|
||||
|
||||
private class RightEdgeAlignedGridDecoration(
|
||||
private val spanCount: Int,
|
||||
private val spacing: Int
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position == RecyclerView.NO_POSITION) {
|
||||
return
|
||||
}
|
||||
val column = position % spanCount
|
||||
outRect.left = column * spacing / spanCount
|
||||
outRect.right = spacing - (column + 1) * spacing / spanCount
|
||||
outRect.bottom = spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @file SquareButton.java
|
||||
* @description 方形按钮文本控件,统一实现以宽定高的测量规则
|
||||
* @author ainetv
|
||||
* @date 2026-04-03
|
||||
*/
|
||||
package com.pukka.mediaapp.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
|
||||
public class SquareTextView extends AppCompatTextView {
|
||||
|
||||
public SquareTextView(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public SquareTextView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public SquareTextView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
int size = getMeasuredWidth();
|
||||
if (size <= 0) {
|
||||
size = MeasureSpec.getSize(widthMeasureSpec);
|
||||
}
|
||||
int squareSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY);
|
||||
super.onMeasure(squareSpec, squareSpec);
|
||||
}
|
||||
}
|
||||
429
app/src/main/java/com/pukka/mediaapp/widget/TopView.java
Normal file
@@ -0,0 +1,429 @@
|
||||
package com.pukka.mediaapp.widget;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.Animatable;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.TextClock;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
||||
import com.facebook.common.executors.CallerThreadExecutor;
|
||||
import com.facebook.common.references.CloseableReference;
|
||||
import com.facebook.datasource.BaseDataSubscriber;
|
||||
import com.facebook.datasource.DataSource;
|
||||
import com.facebook.drawee.backends.pipeline.Fresco;
|
||||
import com.facebook.drawee.controller.ControllerListener;
|
||||
import com.facebook.drawee.interfaces.DraweeController;
|
||||
import com.facebook.drawee.view.SimpleDraweeView;
|
||||
import com.facebook.imagepipeline.image.CloseableImage;
|
||||
import com.facebook.imagepipeline.image.ImageInfo;
|
||||
import com.facebook.imagepipeline.request.ImageRequest;
|
||||
import com.facebook.imagepipeline.request.ImageRequestBuilder;
|
||||
import com.migu.bean.GetTopButtonBean;
|
||||
import com.pukka.mediaapp.R;
|
||||
import com.pukka.mediaapp.utils.TopStyleUtils;
|
||||
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
|
||||
import com.pukka.libcore.fresco.PkSimpleDraweeView;
|
||||
import com.pukka.libcore.utils.ContextUtils;
|
||||
import com.pukka.libcore.utils.LogUtils;
|
||||
|
||||
|
||||
public class TopView extends ConstraintLayout implements View.OnClickListener, View.OnFocusChangeListener {
|
||||
public PkSimpleDraweeView mIvLogo;
|
||||
private SimpleDraweeView mIvSearch;
|
||||
private SimpleDraweeView mIvHistory;
|
||||
private SimpleDraweeView mIvOrder;
|
||||
private SimpleDraweeView mIvService;
|
||||
private SimpleDraweeView mIvHelp;
|
||||
private SimpleDraweeView mIvSetting;
|
||||
private SimpleDraweeView mIvCollect;
|
||||
//private TextView mAmtNotice;
|
||||
//private TextClock mTextClock;
|
||||
// private ConstraintLayout mIvSearch, mIvHistory, mClOrder, mClService, mClHelp, mClSetting, mClCollect;
|
||||
// private TextView mTsearch, mThistory, mTorder, mThelp, mTservice, mTsetting, mTcollect;
|
||||
private String focusSearchImg, focusHistoryImg, focusOrderImg, focusHelpImg, focusServiceImg, focusSettingImg, focusCollectImg;
|
||||
private String noSearchImg, noHistoryImg, noOrderImg, noHelpImg, noServiceImg, noSettingImg, noCollectImg;
|
||||
|
||||
|
||||
/**
|
||||
* 所处的页面类型(首页或二级页)
|
||||
*/
|
||||
private String pageType;
|
||||
|
||||
public TopView(@NonNull Context context) {
|
||||
super(context);
|
||||
initView(context);
|
||||
}
|
||||
|
||||
public TopView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initView(context);
|
||||
}
|
||||
|
||||
public TopView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initView(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置所处的页面类型(首页或二级页)
|
||||
*/
|
||||
public void setPageType(String pageType) {
|
||||
this.pageType = pageType;
|
||||
}
|
||||
|
||||
|
||||
private void initView(Context context) {
|
||||
LayoutInflater.from(context).inflate(R.layout.top_view, this, true);
|
||||
mIvLogo = findViewById(R.id.id_iv_logo);
|
||||
mIvLogo.requestFocus();
|
||||
mIvSearch = findViewById(R.id.id_iv_search);
|
||||
mIvSearch.getHierarchy().setFadeDuration(0);
|
||||
mIvSearch.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
|
||||
mIvHistory = findViewById(R.id.id_iv_history);
|
||||
mIvHistory.getHierarchy().setFadeDuration(0);
|
||||
mIvHistory.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
|
||||
mIvOrder = findViewById(R.id.id_iv_order);
|
||||
mIvOrder.getHierarchy().setFadeDuration(0);
|
||||
mIvOrder.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
|
||||
mIvService = findViewById(R.id.id_iv_service);
|
||||
mIvService.getHierarchy().setFadeDuration(0);
|
||||
mIvService.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
|
||||
mIvHelp = findViewById(R.id.id_iv_help);
|
||||
mIvHelp.getHierarchy().setFadeDuration(0);
|
||||
mIvHelp.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
|
||||
//mAmtNotice = findViewById(R.id.id_atv_notice);
|
||||
mIvSearch.setOnFocusChangeListener(this);
|
||||
mIvSearch.setOnClickListener(this);
|
||||
mIvHistory.setOnFocusChangeListener(this);
|
||||
mIvHistory.setOnClickListener(this);
|
||||
mIvOrder.setOnFocusChangeListener(this);
|
||||
mIvOrder.setOnClickListener(this);
|
||||
mIvService.setOnFocusChangeListener(this);
|
||||
mIvService.setOnClickListener(this);
|
||||
mIvHelp.setOnFocusChangeListener(this);
|
||||
mIvHelp.setOnClickListener(this);
|
||||
mIvSetting = findViewById(R.id.id_iv_setting);
|
||||
mIvSetting.setOnFocusChangeListener(this);
|
||||
mIvSetting.setOnClickListener(this);
|
||||
mIvCollect = findViewById(R.id.id_iv_collect);
|
||||
mIvCollect.setOnFocusChangeListener(this);
|
||||
mIvCollect.setOnClickListener(this);
|
||||
//mTextClock = findViewById(R.id.id_tc_clock);
|
||||
mIvHelp.setNextFocusRightId(R.id.id_iv_help);
|
||||
}
|
||||
|
||||
public interface OnTopButtonClickListener {
|
||||
void onSearchClick();
|
||||
void onHistoryClick();
|
||||
void onOrderClick();
|
||||
void onHelpClick();
|
||||
void onServiceClick();
|
||||
void onSettingClick();
|
||||
void onCollectClick();
|
||||
}
|
||||
|
||||
private OnTopButtonClickListener clickListener;
|
||||
|
||||
public void setOnTopButtonClickListener(OnTopButtonClickListener listener) {
|
||||
this.clickListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
view.requestFocusFromTouch();
|
||||
if (clickListener == null) return;
|
||||
int id = view.getId();
|
||||
if (id == R.id.id_iv_search) clickListener.onSearchClick();
|
||||
else if (id == R.id.id_iv_history) clickListener.onHistoryClick();
|
||||
else if (id == R.id.id_iv_order) clickListener.onOrderClick();
|
||||
else if (id == R.id.id_iv_help) clickListener.onHelpClick();
|
||||
else if (id == R.id.id_iv_service) clickListener.onServiceClick();
|
||||
else if (id == R.id.id_iv_setting) clickListener.onSettingClick();
|
||||
else if (id == R.id.id_iv_collect) clickListener.onCollectClick();
|
||||
}
|
||||
|
||||
public void bindData(GetTopButtonBean response) {
|
||||
if (response != null && response.getData() != null && response.getData().size() > 0) {
|
||||
// 判定TOP数据类别并使用相关数据渲染界面
|
||||
for (int i = 0; i < response.getData().size(); i++) {
|
||||
int type = response.getData().get(i).getType();
|
||||
switch (type) {
|
||||
case 1:
|
||||
setLogo(response.getData().get(i).getBack_url());
|
||||
break;
|
||||
case 2:
|
||||
// String message = response.getData().get(i).getMessage();
|
||||
// if (!TextUtils.isEmpty(message)) {
|
||||
// mAmtNotice.setText(message);
|
||||
// mAmtNotice.setVisibility(View.VISIBLE);
|
||||
// }
|
||||
break;
|
||||
case 3:
|
||||
mIvSearch.setVisibility(View.VISIBLE);
|
||||
mIvSearch.setImageURI(response.getData().get(i).getBack_url());
|
||||
noSearchImg = response.getData().get(i).getBack_url();
|
||||
focusSearchImg = response.getData().get(i).getFocus_picurl();
|
||||
loadImgPre(focusSearchImg, ContextUtils.getContext());
|
||||
break;
|
||||
case 4:
|
||||
mIvHistory.setVisibility(View.VISIBLE);
|
||||
mIvHistory.setImageURI(response.getData().get(i).getBack_url());
|
||||
noHistoryImg = response.getData().get(i).getBack_url();
|
||||
focusHistoryImg = response.getData().get(i).getFocus_picurl();
|
||||
loadImgPre(focusHistoryImg,ContextUtils.getContext());
|
||||
break;
|
||||
case 5:
|
||||
if(BoxSharedParamsUtils.isJcHotel()){
|
||||
break;
|
||||
}
|
||||
mIvOrder.setVisibility(View.VISIBLE);
|
||||
mIvOrder.setImageURI(response.getData().get(i).getBack_url());
|
||||
noOrderImg = response.getData().get(i).getBack_url();
|
||||
focusOrderImg = response.getData().get(i).getFocus_picurl();
|
||||
loadImgPre(focusOrderImg,ContextUtils.getContext());
|
||||
break;
|
||||
case 6:
|
||||
mIvHelp.setVisibility(View.VISIBLE);
|
||||
mIvHelp.setImageURI(response.getData().get(i).getBack_url());
|
||||
noHelpImg = response.getData().get(i).getBack_url();
|
||||
focusHelpImg = response.getData().get(i).getFocus_picurl();
|
||||
loadImgPre(focusHelpImg,ContextUtils.getContext());
|
||||
break;
|
||||
case 7:
|
||||
mIvService.setVisibility(View.VISIBLE);
|
||||
mIvService.setImageURI(response.getData().get(i).getBack_url());
|
||||
noServiceImg = response.getData().get(i).getBack_url();
|
||||
focusServiceImg = response.getData().get(i).getFocus_picurl();
|
||||
break;
|
||||
case 8:
|
||||
mIvSetting.setVisibility(View.VISIBLE);
|
||||
mIvSetting.setImageURI(response.getData().get(i).getBack_url());
|
||||
noSettingImg = response.getData().get(i).getBack_url();
|
||||
focusSettingImg = response.getData().get(i).getFocus_picurl();
|
||||
break;
|
||||
case 9:
|
||||
mIvCollect.setVisibility(View.VISIBLE);
|
||||
mIvCollect.setImageURI(response.getData().get(i).getBack_url());
|
||||
noCollectImg = response.getData().get(i).getBack_url();
|
||||
focusCollectImg = response.getData().get(i).getFocus_picurl();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mIvCollect.getVisibility() == GONE) {
|
||||
if (mIvSetting.getVisibility() == GONE) {
|
||||
if (mIvService.getVisibility() == GONE) {
|
||||
if (mIvOrder.getVisibility() == GONE) {
|
||||
if (mIvHelp.getVisibility() == GONE) {
|
||||
if (mIvHistory.getVisibility() == GONE) {
|
||||
if (mIvSearch.getVisibility() == VISIBLE) {
|
||||
mIvSearch.setNextFocusRightId(R.id.id_iv_search);
|
||||
}
|
||||
} else {
|
||||
mIvHistory.setNextFocusRightId(R.id.id_iv_history);
|
||||
}
|
||||
} else {
|
||||
mIvHelp.setNextFocusRightId(R.id.id_iv_order);
|
||||
}
|
||||
} else {
|
||||
mIvOrder.setNextFocusRightId(R.id.id_iv_help);
|
||||
}
|
||||
} else {
|
||||
mIvService.setNextFocusRightId(R.id.id_iv_service);
|
||||
}
|
||||
} else {
|
||||
mIvSetting.setNextFocusRightId(R.id.id_iv_setting);
|
||||
}
|
||||
}
|
||||
if (mIvSearch.getVisibility() == GONE) {
|
||||
if (mIvHistory.getVisibility() == GONE) {
|
||||
if (mIvOrder.getVisibility() == GONE) {
|
||||
if (mIvHelp.getVisibility() == GONE) {
|
||||
if (mIvService.getVisibility() == GONE) {
|
||||
if (mIvSetting.getVisibility() == GONE) {
|
||||
if (mIvCollect.getVisibility() == VISIBLE) {
|
||||
mIvCollect.setNextFocusRightId(R.id.id_iv_collect);
|
||||
}
|
||||
} else {
|
||||
mIvSetting.setNextFocusLeftId(R.id.id_iv_setting);
|
||||
}
|
||||
} else {
|
||||
mIvService.setNextFocusLeftId(R.id.id_iv_service);
|
||||
}
|
||||
} else {
|
||||
mIvHelp.setNextFocusLeftId(R.id.id_iv_help);
|
||||
}
|
||||
} else {
|
||||
mIvOrder.setNextFocusLeftId(R.id.id_iv_order);
|
||||
}
|
||||
} else {
|
||||
mIvHistory.setNextFocusLeftId(R.id.id_iv_history);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//mTextClock.setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFocusChange(View view, boolean b) {
|
||||
if (b) {
|
||||
if (view.getId() == R.id.id_iv_search) {
|
||||
if (!TextUtils.isEmpty(focusSearchImg)) {
|
||||
mIvSearch.setImageURI(focusSearchImg);
|
||||
}
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_history) {
|
||||
if (!TextUtils.isEmpty(focusHistoryImg)) {
|
||||
mIvHistory.setImageURI(focusHistoryImg);
|
||||
}
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_order) {
|
||||
if (!TextUtils.isEmpty(focusOrderImg)) {
|
||||
mIvOrder.setImageURI(focusOrderImg);
|
||||
}
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_help) {
|
||||
if (!TextUtils.isEmpty(focusHelpImg)) {
|
||||
mIvHelp.setImageURI(focusHelpImg);
|
||||
}
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_service) {
|
||||
if (!TextUtils.isEmpty(focusServiceImg)) {
|
||||
mIvService.setImageURI(focusServiceImg);
|
||||
}
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_setting) {
|
||||
if (!TextUtils.isEmpty(focusSettingImg)) {
|
||||
mIvSetting.setImageURI(focusSettingImg);
|
||||
}
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_collect) {
|
||||
if (!TextUtils.isEmpty(focusCollectImg)) {
|
||||
mIvCollect.setImageURI(focusCollectImg);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (view.getId() == R.id.id_iv_search) {
|
||||
mIvSearch.setImageURI(noSearchImg);
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_history) {
|
||||
mIvHistory.setImageURI(noHistoryImg);
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_order) {
|
||||
mIvOrder.setImageURI(noOrderImg);
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_help) {
|
||||
mIvHelp.setImageURI(noHelpImg);
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_service) {
|
||||
mIvService.setImageURI(noServiceImg);
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_setting) {
|
||||
mIvSetting.setImageURI(noSettingImg);
|
||||
}
|
||||
if (view.getId() == R.id.id_iv_collect) {
|
||||
mIvCollect.setImageURI(noCollectImg);
|
||||
}
|
||||
}
|
||||
TopStyleUtils.topStyleChange(view.getContext(), view, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置logo
|
||||
*
|
||||
* @param logoUrl logo地址
|
||||
*/
|
||||
private void setLogo(String logoUrl) {
|
||||
if (TextUtils.isEmpty(logoUrl)) {
|
||||
mIvLogo.setVisibility(View.INVISIBLE);
|
||||
return;
|
||||
}
|
||||
// DraweeController controller = Fresco.newDraweeControllerBuilder()
|
||||
// .setOldController(mIvLogo.getController())
|
||||
// .setControllerListener(new ControllerListener<ImageInfo>() {
|
||||
// @Override
|
||||
// public void onSubmit(String id, Object callerContext) {
|
||||
//
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onFinalImageSet(String id, ImageInfo imageInfo, Animatable animatable) {
|
||||
// if(mIvLogo.getHeight()==0){
|
||||
// mIvLogo.setVisibility(View.VISIBLE);
|
||||
// mIvLogo.post(new Runnable() {
|
||||
// @Override
|
||||
// public void run() {
|
||||
// float bl = (float) mIvLogo.getHeight() / imageInfo.getHeight();
|
||||
// adjustSdv(imageInfo.getWidth() * bl, imageInfo.getHeight() * bl);
|
||||
// }
|
||||
// });
|
||||
// }else {
|
||||
// float bl = (float) mIvLogo.getHeight() / imageInfo.getHeight();
|
||||
// adjustSdv(imageInfo.getWidth() * bl, imageInfo.getHeight() * bl);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onIntermediateImageSet(String id, ImageInfo imageInfo) {
|
||||
//
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onIntermediateImageFailed(String id, Throwable throwable) {
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onFailure(String id, Throwable throwable) {
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onRelease(String id) {
|
||||
//
|
||||
// }
|
||||
// })
|
||||
// .setUri(Uri.parse(logoUrl))
|
||||
// .build();
|
||||
//
|
||||
// mIvLogo.setController(controller);
|
||||
mIvLogo.setImageURI(logoUrl);
|
||||
mIvLogo.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void adjustSdv(float width, float height) {
|
||||
LayoutParams params = (LayoutParams) mIvLogo.getLayoutParams();
|
||||
params.width = (int) width;
|
||||
params.height = (int) height;
|
||||
mIvLogo.setLayoutParams(params);
|
||||
}
|
||||
/**
|
||||
* 预加载图片到缓存(不显示)
|
||||
* */
|
||||
private void loadImgPre(String imgUrl,Context context){
|
||||
ImageRequest request = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imgUrl))
|
||||
.setCacheChoice(ImageRequest.CacheChoice.DEFAULT)
|
||||
.build();
|
||||
Fresco.getImagePipeline().prefetchToBitmapCache(request, context);
|
||||
}
|
||||
/**
|
||||
* 判断图片是否已经缓存
|
||||
* */
|
||||
public boolean isCached(String imageUrl) {
|
||||
ImageRequest request = ImageRequest.fromUri(imageUrl);
|
||||
return Fresco.getImagePipeline().isInBitmapMemoryCache(request);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
11
app/src/main/res/anim/activation_code_shake.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:fillAfter="false">
|
||||
<translate
|
||||
android:duration="50"
|
||||
android:fromXDelta="-4"
|
||||
android:interpolator="@android:anim/linear_interpolator"
|
||||
android:repeatCount="4"
|
||||
android:repeatMode="reverse"
|
||||
android:toXDelta="4" />
|
||||
</set>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 点击态:颜色稍微变暗提供反馈 -->
|
||||
<item android:state_focused="true" android:color="@color/white" />
|
||||
<item android:state_pressed="true" android:color="@color/white" />
|
||||
<item android:color="@color/activation_text_secondary_new" />
|
||||
</selector>
|
||||
7
app/src/main/res/color/activation_key_text_selector.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true" android:color="@color/white" />
|
||||
<item android:state_focused="true" android:color="@color/white" />
|
||||
<item android:state_selected="true" android:color="@color/white" />
|
||||
<item android:color="@color/black" />
|
||||
</selector>
|
||||
7
app/src/main/res/color/activation_view_text_selector.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true" android:color="@color/white" />
|
||||
<item android:state_focused="true" android:color="@color/white" />
|
||||
<item android:state_selected="true" android:color="@color/white" />
|
||||
<item android:color="@color/activation_text_primary_new" />
|
||||
</selector>
|
||||
BIN
app/src/main/res/drawable-hdpi/gamepad.png
Executable file
|
After Width: | Height: | Size: 91 KiB |
BIN
app/src/main/res/drawable-hdpi/index_bg.png
Executable file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/drawable-hdpi/list_bg.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
app/src/main/res/drawable-hdpi/list_bg_tag_focus.png
Executable file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
app/src/main/res/drawable-hdpi/list_bg_tag_unfocus.png
Executable file
|
After Width: | Height: | Size: 610 B |
BIN
app/src/main/res/drawable-hdpi/list_search_ic.png
Executable file
|
After Width: | Height: | Size: 863 B |
35
app/src/main/res/drawable/bg_activation_action_selector.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/activation_action_focus_start"
|
||||
android:endColor="@color/activation_action_focus_end"
|
||||
android:angle="45" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="2dp" android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<!-- 焦点态 -->
|
||||
<item android:state_focused="true" android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/activation_action_focus_start"
|
||||
android:endColor="@color/activation_action_focus_end"
|
||||
android:angle="45" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="2dp" android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<!-- 普通态 -->
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/activation_action_bg" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<!-- 柔和的边框 -->
|
||||
<stroke android:width="1dp" android:color="@color/activation_border_default" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
23
app/src/main/res/drawable/bg_activation_code_box.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/activation_bg_card" />
|
||||
<corners android:radius="@dimen/qb_px_16" />
|
||||
<!-- <stroke-->
|
||||
<!-- android:width="1dp"-->
|
||||
<!-- android:color="@color/activation_border_blue" />-->
|
||||
</shape>
|
||||
</item>
|
||||
<item
|
||||
android:top="0dp"
|
||||
android:bottom="2dp">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#1A007BFF"
|
||||
android:endColor="#00007BFF"
|
||||
android:angle="90" />
|
||||
<corners android:radius="@dimen/qb_px_16" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
7
app/src/main/res/drawable/bg_activation_device_panel.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#80FFFFFF" />
|
||||
<corners android:radius="@dimen/qb_px_16" />
|
||||
<stroke android:width="1dp" android:color="@color/activation_border_default" />
|
||||
</shape>
|
||||
11
app/src/main/res/drawable/bg_activation_gradient.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle"
|
||||
android:useLevel="false">
|
||||
<solid android:color="#BBDDFF" />
|
||||
<gradient android:type="linear"
|
||||
android:startColor="#F7D4D3"
|
||||
android:centerColor="#FBD8CB"
|
||||
android:endColor="#FFE1B1"
|
||||
android:angle="135" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/white" />
|
||||
<corners android:radius="12dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/activation_border_default" />
|
||||
</shape>
|
||||
46
app/src/main/res/drawable/bg_activation_key_selector.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 点击态 (同时拥有焦点和按下状态,修复TV端点击无反馈问题) -->
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/btn_selector_startColor"
|
||||
android:endColor="@color/btn_selector_endColor"
|
||||
android:angle="45" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="2dp" android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<item android:state_selected="true">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/btn_selector_startColor"
|
||||
android:endColor="@color/btn_selector_endColor"
|
||||
android:angle="45" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="1dp" android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<item android:state_focused="true" >
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/btn_selector_startColor"
|
||||
android:endColor="@color/btn_selector_endColor"
|
||||
android:angle="45" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="2dp" android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
|
||||
<!-- 普通态 -->
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/activation_bg_card" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="1dp" android:color="@color/activation_border_default" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#D9202020" />
|
||||
<corners android:radius="@dimen/qb_px_12" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_button_accent.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#FF8E26"
|
||||
android:endColor="#FF6A00"
|
||||
android:angle="0" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_button_secondary.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#22FFFFFF" />
|
||||
<corners android:radius="24dp" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_card.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#1E1E36"
|
||||
android:endColor="#15152A"
|
||||
android:angle="270" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_card_focus.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#252542" />
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="2dp" android:color="@color/accent" />
|
||||
</shape>
|
||||
8
app/src/main/res/drawable/bg_card_gradient_bottom.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:startColor="#00000000"
|
||||
android:endColor="#CC000000" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_detail_def_selector.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient android:startColor="#ffdeea" android:centerColor="#fff3ed" android:endColor="#bbd9ff" android:angle="315"/>
|
||||
<corners android:radius="@dimen/qb_px_6"/>
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
9
app/src/main/res/drawable/bg_dialog.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#1E1E36"
|
||||
android:endColor="#15152A"
|
||||
android:angle="270" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_dialog_activation.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F5F5F5" />
|
||||
<corners android:radius="@dimen/qb_px_16" />
|
||||
</shape>
|
||||
15
app/src/main/res/drawable/bg_filter_table_page_selector.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient android:startColor="@color/btn_selector_startColor" android:endColor="@color/btn_selector_endColor" android:angle="315"/>
|
||||
<corners android:radius="@dimen/qb_px_32"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<gradient android:startColor="@color/btn_selector_startColor" android:endColor="@color/btn_selector_endColor" android:angle="315"/>
|
||||
<corners android:radius="@dimen/qb_px_32"/>
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
6
app/src/main/res/drawable/bg_list_tag_selector.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/list_bg_tag_focus" android:state_focused="true"/>
|
||||
<item android:drawable="@drawable/list_bg_tag_unfocus" android:state_selected="true"/>
|
||||
<item android:drawable="@color/transparent"/>
|
||||
</selector>
|
||||
14
app/src/main/res/drawable/bg_nav_item_selected.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:bottom="0dp" android:left="0dp" android:right="0dp" android:top="0dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:gravity="bottom">
|
||||
<shape android:shape="rectangle">
|
||||
<size android:height="3dp" />
|
||||
<solid android:color="@color/accent" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
6
app/src/main/res/drawable/bg_nav_selected.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#33FF7417" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_play_button.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="#FF8E26"
|
||||
android:endColor="#FF6A00"
|
||||
android:angle="0" />
|
||||
<corners android:radius="24dp" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_poster_rounded.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_card" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_poster_rounded_top.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/bg_card" />
|
||||
<corners android:topLeftRadius="10dp" android:topRightRadius="10dp"
|
||||
android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_qrcode_white.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
15
app/src/main/res/drawable/bg_search_back_btn_selector.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="oval">
|
||||
<gradient android:startColor="@color/btn_selector_startColor"
|
||||
android:endColor="@color/btn_selector_endColor"
|
||||
android:angle="315"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#33FFFFFF"/>
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
7
app/src/main/res/drawable/bg_search_input.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1A1A2E" />
|
||||
<corners android:radius="24dp" />
|
||||
<stroke android:width="1dp" android:color="@color/divider" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_section_bar.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/accent" />
|
||||
<corners android:radius="2dp" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_vip_tag.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/vip_bg" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
12
app/src/main/res/drawable/filter_mask_bg.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle"
|
||||
android:useLevel="false">
|
||||
<gradient android:type="linear"
|
||||
android:startColor="#00000000"
|
||||
android:endColor="#CC000000"
|
||||
android:angle="270" />
|
||||
<corners
|
||||
android:bottomLeftRadius="@dimen/qb_px_6"
|
||||
android:bottomRightRadius="@dimen/qb_px_6"/>
|
||||
</shape>
|
||||
10
app/src/main/res/drawable/ic_back.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@color/text_primary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FF7417"
|
||||
android:pathData="M27,35h54a6,6 0,0 1,6 6v32a6,6 0,0 1,-6 6h-54a6,6 0,0 1,-6 -6v-32a6,6 0,0 1,6 -6z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M48,48l16,9l-16,9z"/>
|
||||
</vector>
|
||||
6
app/src/main/res/drawable/ic_placeholder.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#333333" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
10
app/src/main/res/drawable/ic_search.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@color/text_primary">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
|
||||
</vector>
|
||||
BIN
app/src/main/res/drawable/icon_detail_back_normal.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/drawable/jst_logo_right.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
25
app/src/main/res/drawable/selector_bg_activate_page_btn.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true" >
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/btn_selector_startColor"
|
||||
android:endColor="@color/btn_selector_endColor"
|
||||
android:angle="45" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="2dp" android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:state_focused="true" >
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/btn_selector_startColor"
|
||||
android:endColor="@color/btn_selector_endColor"
|
||||
android:angle="45" />
|
||||
<corners android:radius="@dimen/qb_px_8" />
|
||||
<stroke android:width="2dp" android:color="@color/white" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:drawable="@drawable/bg_activation_input_preview"/>
|
||||
|
||||
</selector>
|
||||
BIN
app/src/main/res/drawable/sxtitle.png
Executable file
|
After Width: | Height: | Size: 58 KiB |
230
app/src/main/res/layout-land/device_not_activated_view.xml
Normal file
@@ -0,0 +1,230 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/id_ml_main_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_activation_gradient"
|
||||
app:layoutDescription="@xml/device_not_activated_scene">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/id_iv_brand_logo"
|
||||
android:layout_width="@dimen/qb_px_153"
|
||||
android:layout_height="@dimen/qb_px_33"
|
||||
android:src="@drawable/jst_logo_right"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:layout_marginTop="@dimen/qb_px_28"
|
||||
android:layout_marginEnd="@dimen/qb_px_28"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_info_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/str_member_benifest_activate"
|
||||
android:textColor="@color/activation_text_primary_new"
|
||||
android:textSize="@dimen/mg_sp_35"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="@dimen/qb_px_28"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/id_cl_input_display" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/id_cl_device_info"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:paddingStart="@dimen/qb_px_50"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:descendantFocusability="blocksDescendants"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/id_iv_brand_logo"
|
||||
app:layout_constraintEnd_toStartOf="@id/id_cl_input_display"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintHorizontal_weight="1">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/id_rv_device_info"
|
||||
android:layout_width="@dimen/qb_px_380"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false"
|
||||
android:overScrollMode="never"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/id_cl_input_display"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:focusable="false"
|
||||
android:clickable="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_weight="1"
|
||||
app:layout_constraintStart_toEndOf="@id/id_cl_device_info">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_location_tab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="@dimen/qb_px_28"
|
||||
android:maxLines="1"
|
||||
android:text="@string/str_location_tab_title"
|
||||
android:textColor="@color/activation_text_primary_new"
|
||||
android:textSize="@dimen/mg_sp_25"
|
||||
app:layout_constraintEnd_toEndOf="@+id/id_tv_location_placeholder"
|
||||
app:layout_constraintStart_toStartOf="@+id/id_tv_location_placeholder"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_location_placeholder"
|
||||
android:layout_width="@dimen/qb_px_280"
|
||||
android:layout_height="@dimen/qb_px_80"
|
||||
android:gravity="center"
|
||||
android:paddingTop="@dimen/qb_px_15"
|
||||
android:paddingBottom="@dimen/qb_px_15"
|
||||
android:layout_marginTop="@dimen/qb_px_8"
|
||||
android:background="@drawable/selector_bg_activate_page_btn"
|
||||
android:textSize="@dimen/mg_sp_28"
|
||||
android:hint="@string/str_location_placeholder"
|
||||
android:textColorHint="@color/activation_text_secondary_new"
|
||||
android:nextFocusDown="@+id/id_v_activate_code_bg"
|
||||
android:focusable="true"
|
||||
android:clickable="true"
|
||||
android:textStyle="bold"
|
||||
android:lines="1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/id_tv_location_tab"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_location_tips"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/qb_px_15"
|
||||
android:text="@string/str_location_input_hint"
|
||||
android:textColor="@color/activation_text_secondary_new"
|
||||
android:textSize="@dimen/mg_sp_22"
|
||||
app:layout_constraintStart_toStartOf="@id/id_tv_location_placeholder"
|
||||
app:layout_constraintTop_toBottomOf="@id/id_tv_location_placeholder" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_activate_tab"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/qb_px_15"
|
||||
android:gravity="center"
|
||||
android:maxLines="1"
|
||||
android:paddingTop="@dimen/qb_px_10"
|
||||
android:paddingBottom="@dimen/qb_px_10"
|
||||
android:text="@string/str_activate_tab_title"
|
||||
android:textColor="@color/activation_text_primary_new"
|
||||
android:textSize="@dimen/mg_sp_25"
|
||||
app:layout_constraintEnd_toEndOf="@+id/id_tv_location_placeholder"
|
||||
app:layout_constraintStart_toStartOf="@+id/id_tv_location_placeholder"
|
||||
app:layout_constraintTop_toBottomOf="@+id/id_tv_location_tips"/>
|
||||
|
||||
|
||||
<View
|
||||
android:id="@+id/id_v_activate_code_bg"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="@dimen/qb_px_80"
|
||||
android:layout_marginTop="@dimen/qb_px_8"
|
||||
android:focusable="true"
|
||||
android:clickable="true"
|
||||
android:nextFocusUp="@+id/id_tv_location_placeholder"
|
||||
android:background="@drawable/selector_bg_activate_page_btn"
|
||||
app:layout_constraintTop_toBottomOf="@id/id_tv_activate_tab"
|
||||
app:layout_constraintEnd_toEndOf="@id/id_tv_location_placeholder"
|
||||
app:layout_constraintStart_toStartOf="@id/id_tv_location_placeholder" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_activate_placeholder"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:gravity="center"
|
||||
android:paddingStart="@dimen/qb_px_50"
|
||||
android:paddingEnd="@dimen/qb_px_5"
|
||||
android:textStyle="bold"
|
||||
android:textSize="@dimen/mg_sp_28"
|
||||
android:hint="@string/str_activate_placeholder"
|
||||
android:textColorHint="@color/activation_text_secondary_new"
|
||||
android:lines="1"
|
||||
app:layout_constraintTop_toTopOf="@id/id_v_activate_code_bg"
|
||||
app:layout_constraintBottom_toBottomOf="@id/id_v_activate_code_bg"
|
||||
app:layout_constraintEnd_toEndOf="@id/id_v_activate_code_bg"
|
||||
app:layout_constraintStart_toStartOf="@id/id_v_activate_code_bg"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_activate_pre"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/qb_px_15"
|
||||
android:textStyle="bold"
|
||||
android:textSize="@dimen/mg_sp_28"
|
||||
android:text="AB-"
|
||||
android:textColor="@color/black"
|
||||
app:layout_constraintTop_toTopOf="@id/id_v_activate_code_bg"
|
||||
app:layout_constraintStart_toStartOf="@id/id_v_activate_code_bg"
|
||||
app:layout_constraintBottom_toBottomOf="@id/id_v_activate_code_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/id_tv_activate_tips"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/qb_px_15"
|
||||
android:text="@string/str_activate_input_hint"
|
||||
android:textColor="@color/activation_text_secondary_new"
|
||||
android:textSize="@dimen/mg_sp_22"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@id/id_v_activate_code_bg"
|
||||
app:layout_constraintTop_toBottomOf="@id/id_v_activate_code_bg" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/id_cl_activation_input_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="@dimen/qb_px_30"
|
||||
android:layout_marginTop="@dimen/qb_px_20"
|
||||
android:layout_marginEnd="@dimen/qb_px_56"
|
||||
android:layout_marginBottom="@dimen/qb_px_20"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:focusableInTouchMode="false"
|
||||
android:descendantFocusability="afterDescendants"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/id_cl_input_display"
|
||||
app:layout_constraintTop_toBottomOf="@+id/id_iv_brand_logo">
|
||||
|
||||
<com.pukka.mediaapp.widget.LocationCodeInputView
|
||||
android:id="@+id/id_v_location_input_panel"
|
||||
android:layout_width="@dimen/qb_px_400"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:focusableInTouchMode="false"
|
||||
android:descendantFocusability="afterDescendants"
|
||||
android:overScrollMode="never"
|
||||
app:layout_constraintVertical_bias="0.6"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.constraintlayout.motion.widget.MotionLayout>
|
||||
6
app/src/main/res/layout/activity_activation.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.pukka.mediaapp.widget.DeviceNotActivatedView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/activationView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
144
app/src/main/res/layout/activity_detail.xml
Normal file
@@ -0,0 +1,144 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 详情页 -->
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/bg_main"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="@dimen/qb_px_40">
|
||||
|
||||
<!-- 顶部 16:9 横幅背景图 -->
|
||||
<ImageView
|
||||
android:id="@+id/ivBackdrop"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<!-- 海报 + 信息区 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="-40dp"
|
||||
android:paddingHorizontal="@dimen/qb_px_50">
|
||||
|
||||
<!-- 海报 -->
|
||||
<ImageView
|
||||
android:id="@+id/ivPoster"
|
||||
android:layout_width="130dp"
|
||||
android:layout_height="182dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:background="@drawable/bg_poster_rounded"
|
||||
android:clipToOutline="true"
|
||||
android:outlineProvider="background"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<!-- 右侧信息区 -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="@dimen/qb_px_24"
|
||||
android:layout_marginBottom="-40dp">
|
||||
|
||||
<!-- 标题 -->
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="@dimen/mg_sp_28"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
android:maxWidth="400dp" />
|
||||
|
||||
<!-- 元信息:分类 · 地区 · 年份 · 时长 -->
|
||||
<TextView
|
||||
android:id="@+id/tvType"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/qb_px_8"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="@dimen/mg_sp_16" />
|
||||
|
||||
<!-- 分割线 -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="@dimen/qb_px_10"
|
||||
android:layout_marginBottom="@dimen/qb_px_10"
|
||||
android:background="@color/divider" />
|
||||
|
||||
<!-- 演员 -->
|
||||
<TextView
|
||||
android:id="@+id/tvActor"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="@dimen/mg_sp_16"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end"
|
||||
android:lineSpacingExtra="2dp" />
|
||||
|
||||
<!-- 简介 -->
|
||||
<TextView
|
||||
android:id="@+id/tvIntro"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/qb_px_8"
|
||||
android:textColor="@color/text_tertiary"
|
||||
android:textSize="@dimen/mg_sp_16"
|
||||
android:maxLines="4"
|
||||
android:ellipsize="end"
|
||||
android:lineSpacingExtra="@dimen/qb_px_4" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 操作按钮区 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="@dimen/qb_px_24"
|
||||
android:paddingHorizontal="@dimen/qb_px_50">
|
||||
|
||||
<!-- 播放按钮 -->
|
||||
<TextView
|
||||
android:id="@+id/btnPlay"
|
||||
android:layout_width="140dp"
|
||||
android:layout_height="52dp"
|
||||
android:text="▶ 播放"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="@dimen/mg_sp_20"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:background="@drawable/bg_play_button"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ImageView
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="@dimen/qb_px_40"
|
||||
android:layout_height="@dimen/qb_px_40"
|
||||
android:layout_marginStart="@dimen/qb_px_20"
|
||||
android:src="@drawable/ic_back"
|
||||
android:contentDescription="@string/back"
|
||||
android:background="?selectableItemBackgroundBorderless" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
158
app/src/main/res/layout/activity_filter.xml
Normal file
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginHorizontal="@dimen/qb_px_55"
|
||||
tools:ignore="MissingDefaultResource">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/rltop"
|
||||
android:visibility="gone"
|
||||
android:paddingTop="@dimen/qb_px_40"
|
||||
android:paddingBottom="@dimen/qb_px_20"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/titleImg"
|
||||
android:layout_width="@dimen/qb_px_36"
|
||||
android:layout_height="@dimen/qb_px_37"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:src="@drawable/sxtitle"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
app:layout_constraintLeft_toRightOf="@id/titleImg"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/index_nav_text_nor"
|
||||
android:textSize="@dimen/mg_sp_25"
|
||||
android:textStyle="bold"
|
||||
android:text="筛选"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFilterTotal"
|
||||
android:visibility="gone"
|
||||
android:layout_marginLeft="@dimen/qb_px_16"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@id/title"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FF7417"
|
||||
android:textSize="@dimen/mg_sp_21"
|
||||
android:hint="中国大陆 > 中国大陆 > 中国大陆 > 2025-2024"
|
||||
android:layout_alignParentRight="true"
|
||||
android:text="全部 > 全部 > 全部 > 全部"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBack"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintRight_toLeftOf="@id/num"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_marginRight="@dimen/qb_px_22"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/index_nav_text_nor"
|
||||
android:textSize="@dimen/mg_sp_21"
|
||||
android:layout_alignParentRight="true"
|
||||
android:text="按“返回”键打开筛选列表"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/num"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/index_nav_text_nor"
|
||||
android:textSize="@dimen/mg_sp_21"
|
||||
android:layout_alignParentRight="true"
|
||||
android:text="共0部"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/rltop"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/contentInfoRv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:nextFocusLeft="@id/contentInfoRv"
|
||||
android:nextFocusRight="@id/contentInfoRv"
|
||||
android:orientation="horizontal"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
tools:itemCount="5"
|
||||
tools:listitem="@layout/item_content" />
|
||||
|
||||
<View
|
||||
android:id="@+id/focusView"
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="1dp"/>
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/rltop2"
|
||||
android:visibility="gone"
|
||||
android:paddingLeft="@dimen/qb_px_5"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
android:layout_width="match_parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:layout_height="@dimen/qb_px_50">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/titleImg2"
|
||||
android:layout_width="@dimen/qb_px_33"
|
||||
android:layout_height="@dimen/qb_px_33"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:src="@drawable/sxtitle"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFilterTotal2"
|
||||
android:visibility="visible"
|
||||
android:layout_marginLeft="@dimen/qb_px_7"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@id/titleImg2"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FF7417"
|
||||
android:textSize="@dimen/mg_sp_21"
|
||||
android:hint="中国大陆 > 中国大陆 > 中国大陆 > 2025-2024"
|
||||
android:text="全部 > 全部 > 全部 > 2025-2024"/>
|
||||
|
||||
|
||||
<TextView
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/index_nav_text_nor"
|
||||
android:textSize="@dimen/mg_sp_21"
|
||||
android:layout_alignParentRight="true"
|
||||
android:text="按“返回”键打开筛选列表"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||