commit 0c26f6da6828bf16fd47b35ee8f4040f6cfde747 Author: zijunchiang Date: Fri Sep 4 21:23:21 2026 +0800 麦动KTV: 移除Gitee数据库(改官方Muse同步), 歌曲源切w.w345.my, 远程日志+广告屏蔽 - DatabaseBootstrapper: 删除 Gitee 分片/manifest/blob 下载逻辑, 曲库只走官方 MuseDbSync - build.gradle: 移除 GITEE_DATABASE_* 构建参数 - MainActivity: 重置数据库文案改为官方同步 - ktv_api.js: 歌曲源仅 w.w345.my, 缓存标记 7, 广告/错号资源拦截+重试 - 远程日志: 点歌记录 编号/标题/请求源/状态 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..322c142 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.gradle/ +.idea/ +local.properties +**/build/ +app/release/ +*.apk +/*.png +/*.xml +/*.log +/*.txt +.codex-* +captures/ +**/captures/ +*.xml.bak +# 曲库不再入库/发布到 Gitee(官方 Muse 同步), 整目录忽略 +database_publish/ +gradle.properties + + +# Temp files +.tmp_muse.db +tools/__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e472c8e --- /dev/null +++ b/LICENSE @@ -0,0 +1,49 @@ +麦动 App (Maidong App) 软件许可及非商用协议 + +版权所有 (c) 2026 麦动开发者 (杨家三郎) + +本软件(包括但不限于源代码、编译后的APK二进制文件、界面设计、音频处理算法等)免费对公众开放,并允许个人进行非营利性学习、研究和使用。 + +一旦您使用、复制、修改或分发本软件,即表示您完全同意并接受以下条款: + +1. 禁止营利性使用 +本软件严禁任何形式的营利性/商业性使用。营利性使用包括但不限于: +- 直接售卖本软件或其修改版、衍生版。 +- 将本软件(APK或源码)作为收费系统、定制ROM、电视盒子硬件的内置、预装或捆绑软件进行销售或出租。 +- 在本软件中植入任何形式的广告、第三方统计/推广SDK以获取收益。 +- 围绕本软件提供收费的安装、技术支持、激活或会员增值服务。 +- 利用本软件的代码或其编译产物,进行任何商业性演出、K歌房(KTV)商业运营或收费体验活动。 + +2. 源码修改与分发 +如果您对本源码进行了修改、二次开发并进行分发: +- 您必须完整保留此《许可及非商用协议》,且您的衍生版本必须继续以完全免费、开源、禁止商用的形式发布。 +- 您必须在衍生版本的显著位置(如关于页面、README)注明原作者信息及原项目地址。 + +3. 免责声明 +本软件按“现状”提供,不带有任何明示或暗示的担保(包括但不限于对音质、低延迟、设备兼容性的担保)。在任何情况下,作者均不对因使用本软件而产生的任何直接、间接、偶然的损害承担责任。 + +--- + +Maidong App Software License and Non-Commercial Agreement + +Copyright (c) 2026 Maidong Developer Team (YnagJiaSanLang) + +This software (including but not limited to source code, compiled APK binaries, UI design, and audio processing algorithms) is provided free of charge to the public and is allowed for personal non-profit learning, research, and usage. + +By using, copying, modifying, or distributing this software, you agree to the following terms: + +1. Prohibition of Commercial/For-Profit Use +Any commercial or for-profit use of this software is strictly prohibited. Commercial use includes, but is not limited to: +- Directly selling this software or its modified/derived versions. +- Pre-installing, bundling, or embedding this software (APK or source code) as part of paid operating systems, custom ROMs, or TV box hardware for sale or rent. +- Embedding any form of advertising or third-party ad/tracking SDKs to generate revenue. +- Providing paid installation, technical support, activation, or premium membership services based on this software. +- Using this software or its compiled binaries for commercial performances, commercial KTV operations, or paid experience events. + +2. Modification and Distribution +If you modify, re-develop, and distribute this source code: +- You must keep this agreement intact, and your derived versions must also be distributed for free and strictly for non-commercial use. +- You must clearly credit the original author and include a link to the original project. + +3. Disclaimer +This software is provided "as is", without warranty of any kind. In no event shall the authors be liable for any claim, damages, or other liability arising from the use of this software. \ No newline at end of file diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..963177b --- /dev/null +++ b/README.en.md @@ -0,0 +1,58 @@ +

+ Maidong KTV Logo +

+ +

Maidong KTV

+ +

A local karaoke application for Android TV and landscape song-selection devices

+ +

Remote Control · Song Discovery · Request Queue · Download Management · Stable Playback

+ +## Core Features + +- Full TV remote navigation with directional keys, confirmation, back, and long-press actions. +- Consistent focus feedback for buttons, lists, cards, switches, and dialogs. +- Home entries for charts, song titles, singers, frequently played songs, favorites, categories, and local songs. +- Online and local search with language filters, paging, and initial-letter keyboard input. +- Singer browsing with portrait tags and singer-specific song lists. +- Song ordering, batch ordering, queue prioritization, favorites, and file management. +- Real-time synchronization across queued, played, and downloaded song lists. +- New requests join the end of the queue without interrupting the current requested song. +- Automatic playback when requested songs are available, including smooth transitions from public playback. + +## Playback + +- IJK-based playback for local and downloaded songs. +- Continuous playback while navigating between pages or switching between windowed and full-screen modes. +- Play, pause, skip, replay, progress display, and seeking controls. +- Original-vocal and accompaniment switching without changing the current play or pause state. +- Synchronized playback state across windowed controls, full-screen controls, and status overlays. +- Song title overlays, temporary mode prompts, and persistent pause indicators. +- Video ratio, automatic full screen, audio-video synchronization, and public playback settings. +- Maximum song volume and configurable original-vocal and accompaniment behavior. + +## Downloads And Data + +- Background downloads with progress, resume support, and automatic retry. +- Automatic cleanup and retry after download or decryption failures. +- Recognition, counting, and playback of downloaded local songs. +- Segmented database download, verification, merge, and atomic update. +- Directory creation, database scanning, and download startup only after storage permission is granted. +- Local disk and USB song scanning, reserved storage settings, and automatic cleanup. +- Unified Maidong KTV data storage to avoid conflicts with other applications. + +## Settings + +- Data settings for library updates, local scanning, reserved space, automatic cleanup, and database maintenance. +- Playback settings for public playback, automatic full screen, video ratio, synchronization, and title overlays. +- Sound settings for volume behavior, maximum song volume, original vocals, and accompaniment. +- Interface settings for language, floating controls, carousel content, and song title overlays. +- Dialog selections remain temporary until confirmed and provide clear remote-control focus feedback. + +## Interface And Interaction + +- Landscape TV layout optimized for long-distance viewing. +- Consistent state and meaning across top, windowed, and full-screen playback controls. +- Stable focus during list refreshes, paging, tab changes, and song ordering. +- Focus changes color only and preserves each control's original dimensions, corners, and layout. +- A consistent circular Maidong logo across the home screen, player, and application information. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccabb0c --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +

+ 麦动 KTV Logo +

+ +

麦动 KTV

+ +

面向 Android TV 和横屏点歌设备的本地 KTV 应用

+ +

遥控器操作 · 歌曲检索 · 点播队列 · 下载管理 · 稳定播放

+ +## ⚠️ 严正声明 (Important Notice) + +本软件是**完全免费且公开源码**的个人学习与技术交流项目。 + +- **【禁止任何形式的营利性使用】**:本软件(包括源码和编译后的 APK)严禁任何商家或个人进行打包售卖、硬件预装、收费技术支持或商业运营。 +- **【侵权必究】**:如果您是付费购买的本软件,说明您已被骗,请立即向平台举报商家并退款。 +- 完整授权条款请参见项目根目录下的 [LICENSE](./LICENSE) 文件。 + +## 核心功能 + +- TV 遥控器全局导航,支持方向键、确认键、返回键和长按操作。 +- 统一焦点反馈,按钮、列表、卡片、开关和弹窗均提供清晰的选中状态。 +- 首页提供排行榜、歌名、歌星、常唱、收藏、分类和本地歌曲入口。 +- 支持全网与本地歌曲检索、语种筛选、分页浏览和首字母键盘输入。 +- 歌星页面支持头像标签分类和歌手歌曲浏览。 +- 支持歌曲点播、批量点歌、置顶、收藏、取消收藏和文件管理。 +- 已点、已唱和下载列表实时同步歌曲状态。 +- 已下载歌曲直接加入队列;未下载歌曲完成下载后自动加入,不打断当前播放。 +- 已点列表存在歌曲时自动开始播放,并在切歌后同步队列与播放状态。 + +## 播放功能 + +- 使用 IJK 播放器处理本地和下载歌曲。 +- 支持小窗播放与全屏播放,切换页面和执行点歌操作时保持视频连续播放。 +- 支持播放、暂停、切歌、重唱、进度显示和拖动。 +- 支持原唱与伴唱切换,并保持当前播放或暂停状态。 +- 播放状态在小窗、全屏控制栏和提示浮层之间实时同步。 +- 支持歌曲片头信息、播放提示和暂停常驻提示。 +- 支持视频比例、自动全屏和音画同步设置。 +- 支持歌曲最大音量及原唱、伴唱相关声音策略。 + +## 下载与数据 + +- 支持歌曲后台下载、进度展示、断点恢复和失败重试。 +- 下载或解密异常时自动清理损坏数据并重新发起任务。 +- 支持已下载歌曲识别、数量统计和本地文件播放。 +- 支持分片数据库下载、校验、合并和原子更新。 +- 存储权限完成后再启动目录创建、数据库扫描和下载任务。 +- 支持本地硬盘与 U 盘歌曲扫描、存储空间设置和自动删歌。 +- 预留空间提供遥控器可直接选择的容量档位;达到阈值后保护当前及已点歌曲,并按最近使用时间自动清理。 +- 应用数据统一保存在麦动 KTV 目录,避免与其他应用的数据冲突。 + +## 设置功能 + +- 数据设置:曲库更新、本地扫描、预留空间、自动删歌和数据库维护。 +- 播放设置:自动全屏、视频比例、音画同步和片头字幕。 +- 声音设置:音量策略、歌曲最大音量及原唱伴唱策略。 +- 界面设置:语言、悬浮按钮、轮播图和歌曲片头字幕。 +- 设置弹窗支持临时选择、确认保存和关闭取消,遥控器焦点状态清晰可见。 +- 启动时自动检查 Gitee Release;发现更高版本后可在应用内下载并启动系统安装器。 + +## 界面与交互 + +- 界面同时适配电视、横屏手机和平板;窄屏自动压缩栏高、留白、字号和播放器侧栏。 +- 全屏播放右上角常驻小型手机点歌二维码。 +- 顶部播放控制、小窗控制和全屏控制保持一致的状态与语义。 +- 列表刷新、分页、标签切换和点歌后保持真实焦点位置,避免焦点跳动。 +- 焦点选中仅改变颜色,不改变控件原有尺寸、圆角或布局。 +- 使用麦动圆形 Logo,并在首页、播放页和应用信息中保持统一显示。 + +## 热更新与应用发布 + +- 歌曲接口逻辑位于 `app/src/main/assets/mobile/ktv_api.js`,安装后的应用会从本仓库自动拉取兼容版本。 +- JS 热更新文件必须保留 `KTV_BRIDGE_API: 2` 标记,未通过兼容校验的远端脚本不会覆盖内置版本。 +- 应用更新以 Gitee Release 的标签作为版本号,例如 `1.3.0`;Release 中必须包含一个 `.apk` 附件。 +- 只有标签版本高于当前 `BuildConfig.VERSION_NAME` 时,应用才会提示下载更新。 + +## 赞助支持 + +- 感谢各位的使用与支持,留下您的一点小心意,请我媳妇儿喝杯奶茶吧~~ + +![输入图片说明](WX_resized.jpg) diff --git a/WX_resized.jpg b/WX_resized.jpg new file mode 100644 index 0000000..169ebf2 Binary files /dev/null and b/WX_resized.jpg differ diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..dd2af51 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,95 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" +} + +android { + namespace "com.local.ktv" + compileSdk 35 + + defaultConfig { + applicationId "com.local.ktv" + minSdk 21 + targetSdk 25 + versionCode 5 + versionName "1.2.5" + ndk { + // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 + // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 + // 会出现 INSTALL_FAILED_NO_MATCHING_ABIS 安装失败)。 + // Package each ABI's complete IJK set; never mix individual SOs across the two sets. + abiFilters "armeabi-v7a", "arm64-v8a" + } + // 曲库已改为官方 Muse 同步(database_publish 不再发布到 Gitee), + // 不再需要 GITEE_DATABASE_* 构建参数。 + } + + // 签名配置:使用已有的 ktv_keystore.jks + signingConfigs { + release { + storeFile file("../../ktv_keystore.jks") + storePassword "123456" + keyAlias "ktv" + keyPassword "123456" + } + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + signingConfig signingConfigs.release + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + debuggable true + applicationIdSuffix ".qa" + } + } + + // 支持 .ts 视频文件(aapt 不压缩这些文件以避免 SoundPool/MediaPlayer 问题) + androidResources { + noCompress 'ts', 'mp3', 'mp4', 'aac', 'm4a', 'flac', 'wav' + } + + // 关闭发布构建的 lint vital 检查(本地分发的 APK 不需要 Google Play 上架要求) + lint { + checkReleaseBuilds false + abortOnError false + } + + // 启用 BuildConfig 生成,供 AboutFragment 读取版本号/版本Code等信息 + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + +} + +// 强制统一 Kotlin 标准库版本,避免 appcompat 与其他依赖的版本冲突 +configurations.all { + resolutionStrategy { + force "org.jetbrains.kotlin:kotlin-stdlib:1.8.22" + force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22" + force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22" + force "org.jetbrains.kotlin:kotlin-stdlib-common:1.8.22" + } +} + +dependencies { + implementation files('libs/original-ijk-runtime.jar') + implementation "androidx.core:core:1.12.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "com.google.zxing:core:3.5.3" + implementation "com.liulishuo.okdownload:okdownload:1.0.7" + testImplementation "junit:junit:4.13.2" + +} diff --git a/app/libs/original-ijk-runtime.jar b/app/libs/original-ijk-runtime.jar new file mode 100644 index 0000000..944d228 Binary files /dev/null and b/app/libs/original-ijk-runtime.jar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..185582f --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# The original IJK playback runtime resolves Java classes and native methods by name. +-keep class tv.danmaku.ijk.media.player.** { *; } +-keepclasseswithmembernames class * { native ; } +-keep class com.local.ktv.KtvApplication { *; } +-keep class com.local.ktv.MainActivity { *; } +-keep class com.local.ktv.BootReceiver { *; } +-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b09ac7b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/mobile/app.css b/app/src/main/assets/mobile/app.css new file mode 100644 index 0000000..e8da474 --- /dev/null +++ b/app/src/main/assets/mobile/app.css @@ -0,0 +1,17 @@ +:root{color-scheme:dark;--gold:#ffd56a;--gold2:#ffb23f;--violet:#9c4dff;--magenta:#ec5acc;--bg:#10031f;--glass:rgba(255,255,255,.075);--glass2:rgba(255,255,255,.115);--line:rgba(255,255,255,.12);--muted:#b9a9c9;--green:#62e6a5;--danger:#ff769a}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html{background:#10031f}body{margin:0;min-height:100vh;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:linear-gradient(155deg,#260943 0,#10031f 56%,#080110 100%);overflow-x:hidden}button,input{font:inherit}button{border:0;color:inherit;cursor:pointer}.aurora{position:fixed;border-radius:50%;filter:blur(70px);opacity:.48;pointer-events:none}.aurora-one{width:280px;height:280px;background:#7d20d7;right:-110px;top:-80px}.aurora-two{width:260px;height:260px;background:#d82c9e;left:-150px;bottom:80px;opacity:.23}.mesh{position:fixed;inset:0;pointer-events:none;opacity:.22;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:28px 28px;mask-image:linear-gradient(to bottom,#000,transparent 80%)}.app-shell{position:relative;z-index:1;width:min(100%,520px);margin:auto;min-height:100vh;padding:env(safe-area-inset-top) 0 calc(92px + env(safe-area-inset-bottom))}.topbar{height:94px;padding:18px 20px 12px;display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:11px}.brand-mark{position:relative;width:42px;height:42px;border-radius:13px;background:linear-gradient(145deg,#fff2aa,#ffba44);box-shadow:0 8px 25px rgba(255,190,65,.25);display:flex;align-items:end;justify-content:center;gap:3px;padding:10px}.brand-mark i{display:block;width:4px;border-radius:3px;background:#6a2383}.brand-mark i:nth-child(1){height:12px}.brand-mark i:nth-child(2){height:22px}.brand-mark i:nth-child(3){height:16px}.brand h1{margin:0;font-size:21px;letter-spacing:.5px}.brand h1 em{font-style:italic;color:var(--gold);font-size:15px}.brand p{margin:5px 0 0;color:#c9b9d6;font-size:11px}.brand p span{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green);margin-right:5px}.queue-badge{display:flex;align-items:center;gap:8px;background:linear-gradient(135deg,rgba(255,213,106,.16),rgba(255,165,55,.06));border:1px solid rgba(255,213,106,.2);padding:8px 11px;border-radius:16px}.queue-badge b{min-width:24px;height:24px;padding:0 5px;border-radius:9px;display:grid;place-items:center;background:linear-gradient(135deg,var(--gold),var(--gold2));color:#431440;font-size:12px}.queue-badge span{color:#f7dfaa;font-size:11px}.page{display:none;padding:8px 18px 22px;animation:fadeUp .25s ease}.page.active{display:block}@keyframes fadeUp{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}.hero-card{position:relative;display:flex;align-items:center;gap:18px;min-height:150px;padding:22px;overflow:hidden;border-radius:28px;background:linear-gradient(135deg,rgba(141,49,218,.64),rgba(95,28,147,.42) 56%,rgba(240,78,177,.23));border:1px solid rgba(255,255,255,.16);box-shadow:0 20px 50px rgba(3,0,12,.28),inset 0 1px rgba(255,255,255,.12)}.hero-glow{position:absolute;width:160px;height:160px;border-radius:50%;background:#f058bd;filter:blur(60px);right:-70px;top:-80px;opacity:.42}.album{position:relative;flex:0 0 94px;width:94px;height:94px;border-radius:23px;background:linear-gradient(145deg,#f1a059,#ce3ac1 48%,#6532c8);padding:7px;box-shadow:0 12px 30px rgba(20,0,35,.4)}.disc{height:100%;border-radius:18px;display:grid;place-items:center;background:repeating-radial-gradient(circle,#1d0e2b 0 4px,#2b143b 5px 8px)}.disc:after{content:"";position:absolute;width:24px;height:24px;border-radius:50%;background:linear-gradient(145deg,var(--gold),#e977ad)}.disc span{font-size:24px;color:white;z-index:2}.hero-copy{position:relative;min-width:0;flex:1}.eyebrow{font-size:11px;color:#eadff3;letter-spacing:1px}.playing-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);margin-right:6px}.hero-copy h2{font-size:25px;margin:10px 0 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hero-copy p{margin:0;color:#d6c3e4;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sound-wave{display:flex;align-items:end;gap:3px;height:16px;margin-top:14px}.sound-wave i{width:3px;border-radius:3px;background:linear-gradient(to top,var(--gold2),#fff1a2);animation:wave .9s ease-in-out infinite alternate}.sound-wave i:nth-child(1),.sound-wave i:nth-child(5){height:5px}.sound-wave i:nth-child(2),.sound-wave i:nth-child(6){height:10px;animation-delay:.2s}.sound-wave i:nth-child(3){height:15px;animation-delay:.4s}.sound-wave i:nth-child(4),.sound-wave i:nth-child(7){height:8px;animation-delay:.1s}@keyframes wave{to{height:4px}}.controls{display:grid;grid-template-columns:1fr 1.18fr 1fr;align-items:center;gap:14px;padding:23px 8px 19px}.controls button{height:72px;border-radius:23px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;align-items:center;justify-content:center;color:#dcd0e7;box-shadow:inset 0 1px rgba(255,255,255,.05)}.controls .control-icon{font-size:23px;line-height:24px;font-weight:300}.controls button>span:last-child{font-size:11px;margin-top:7px}.controls .primary{height:82px;border-radius:27px;background:linear-gradient(145deg,#b551ff,#7028d8);color:#fff;box-shadow:0 13px 30px rgba(119,35,219,.4),inset 0 1px rgba(255,255,255,.25)}.glass-card{background:linear-gradient(145deg,rgba(255,255,255,.105),rgba(255,255,255,.05));border:1px solid var(--line);border-radius:24px;box-shadow:inset 0 1px rgba(255,255,255,.08)}.audio-panel{padding:18px}.panel-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.panel-title>div{display:flex;gap:9px;align-items:center}.panel-title b{font-size:15px}.panel-title small{color:#9f8eb0;font-size:10px}.tiny-icon{color:var(--gold);font-size:17px}.seg{display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:5px;background:rgba(9,1,18,.38);border:1px solid rgba(255,255,255,.05);border-radius:17px}.seg button{height:46px;border-radius:13px;background:transparent;color:#a898b7}.seg button span{display:inline-grid;place-items:center;width:24px;height:24px;margin-right:7px;border-radius:8px;background:rgba(255,255,255,.08);font-size:11px}.seg button.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700;box-shadow:0 7px 18px rgba(255,184,74,.18)}.seg button.active span{background:rgba(62,13,57,.13)}.audio-panel label{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:19px;font-size:13px;color:#e9dfef}.audio-panel label>span{display:flex;align-items:center}.audio-panel label>b{color:var(--gold);font-size:12px}.audio-panel label b em{font-style:normal;font-size:15px}.volume-icon{display:inline-grid;place-items:center;width:27px;height:27px;margin-right:9px;border-radius:9px;background:rgba(168,77,255,.16);color:#ca8cff;font-style:normal}.volume-icon.mic{font-size:8px;color:#ff82cc}input[type=range]{grid-column:1/3;width:100%;height:4px;margin:15px 0 2px;appearance:none;border-radius:8px;background:linear-gradient(90deg,#a649f3,#e457c1);accent-color:var(--gold)}input[type=range]::-webkit-slider-thumb{appearance:none;width:19px;height:19px;border-radius:50%;background:#fff;border:5px solid var(--gold);box-shadow:0 3px 10px rgba(0,0,0,.35)}.next-card{display:flex;align-items:center;gap:12px;padding:15px 16px;margin-top:14px;border-radius:19px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.08)}.next-number{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;background:rgba(255,213,106,.12);color:var(--gold);font-size:11px}.next-card>div:nth-child(2){min-width:0;flex:1}.next-card small{display:block;color:#9885a8;font-size:10px;margin-bottom:4px}.next-card b{display:block;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.next-card button{background:transparent;color:var(--gold);font-size:11px}.next-card button span{font-size:17px;margin-left:3px}.page-heading{display:flex;align-items:end;justify-content:space-between;padding:7px 2px 18px}.page-heading p{margin:0 0 5px;color:#a378c7;letter-spacing:2px;font-size:9px}.page-heading h2{font-size:25px;margin:0}.page-heading>span{color:#a898b7;font-size:11px}.search-box{display:flex;align-items:center;gap:7px;height:58px;padding:6px 6px 6px 16px;border-radius:19px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.13);box-shadow:0 12px 28px rgba(0,0,0,.13)}.search-icon{width:16px;height:16px;border:2px solid #bfa9cf;border-radius:50%;position:relative}.search-icon:after{content:"";position:absolute;width:7px;height:2px;background:#bfa9cf;right:-5px;bottom:-3px;transform:rotate(45deg);border-radius:2px}.search-box input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:white;font-size:14px}.search-box input::placeholder{color:#9484a4}.search-box button,.more{height:45px;padding:0 20px;border-radius:14px;background:linear-gradient(135deg,#a74df5,#7130d5);font-weight:700;box-shadow:0 7px 17px rgba(120,45,210,.25)}.hot-block{margin-top:25px}.block-title{display:flex;align-items:center;justify-content:space-between}.block-title h3{font-size:16px;margin:0}.block-title span{font-size:10px;color:#907e9f}.chips{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:14px}.chips button{height:51px;padding:0 14px;text-align:left;border-radius:16px;background:linear-gradient(135deg,rgba(255,255,255,.085),rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.08);color:#e7dcec}.chips button b{display:inline-block;margin-right:10px;color:#c17af6;font-size:10px}.list{margin-top:15px}.song{display:flex;align-items:center;gap:12px;min-height:70px;padding:11px 10px;margin-bottom:9px;border-radius:18px;background:linear-gradient(135deg,rgba(255,255,255,.085),rgba(255,255,255,.04));border:1px solid rgba(255,255,255,.085)}.song-index{flex:0 0 37px;width:37px;height:37px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(135deg,rgba(156,77,255,.25),rgba(236,90,204,.13));color:#c991f3;font-size:11px}.song .info{flex:1;min-width:0}.song .info b{display:block;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.song .info p{margin:5px 0 0;color:#9e8cad;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.song .playing{display:inline-block;margin-bottom:5px;padding:3px 7px;border-radius:8px;color:#6ff0ac;background:rgba(76,225,150,.1);font-size:9px}.song button{height:38px;padding:0 13px;border-radius:12px;font-size:11px}.song .order,.song .top{background:linear-gradient(135deg,rgba(255,213,106,.2),rgba(255,176,57,.1));border:1px solid rgba(255,213,106,.2);color:var(--gold)}.song .order:disabled{color:var(--green);background:rgba(98,230,165,.1);border-color:rgba(98,230,165,.15)}.song .delete{width:38px;padding:0;background:rgba(255,118,154,.1);color:var(--danger)}.queue-tip{padding:11px 13px;border-radius:14px;background:rgba(255,213,106,.07);color:#c9b687;font-size:10px}.queue-tip span{display:inline-grid;place-items:center;width:18px;height:18px;margin-right:7px;border-radius:50%;background:rgba(255,213,106,.14);color:var(--gold)}.more{width:100%;margin:17px 0;color:#fff;border:0}.empty{padding:80px 15px;text-align:center;color:#927fa2}.empty:before{content:"♫";display:grid;place-items:center;width:58px;height:58px;margin:0 auto 14px;border-radius:19px;background:rgba(255,255,255,.06);color:#a970d3;font-size:25px}.hidden{display:none!important}nav{position:fixed;z-index:8;left:50%;bottom:0;transform:translateX(-50%);width:min(100%,520px);height:78px;padding:5px 15px env(safe-area-inset-bottom);display:grid;grid-template-columns:repeat(3,1fr);background:rgba(14,3,27,.9);backdrop-filter:blur(22px);border-top:1px solid rgba(255,255,255,.09)}nav button{position:relative;background:transparent;color:#887897;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:10px}nav button.active{color:var(--gold)}nav i{display:block;position:relative;width:21px;height:21px}.nav-remote:before{content:"";position:absolute;inset:2px 4px;border:2px solid currentColor;border-radius:6px}.nav-remote:after{content:"";position:absolute;width:4px;height:4px;border-radius:50%;background:currentColor;left:9px;top:8px}.nav-search{border:2px solid currentColor;border-radius:50%;width:16px;height:16px;margin-bottom:5px}.nav-search:after{content:"";position:absolute;width:8px;height:2px;background:currentColor;right:-6px;bottom:-4px;transform:rotate(45deg)}.nav-queue:before{content:"";position:absolute;inset:3px 2px;border-top:2px solid currentColor;border-bottom:2px solid currentColor;box-shadow:0 5px 0 -3px currentColor}.nav-queue:after{content:"";position:absolute;width:3px;height:3px;left:-3px;top:2px;background:currentColor;box-shadow:0 7px currentColor,0 14px currentColor}nav button>b{position:absolute;top:3px;left:calc(50% + 8px);min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:#ed4f86;color:#fff;font-size:9px;display:grid;place-items:center}.offline{position:fixed;z-index:20;top:0;left:0;right:0;padding:9px;background:rgba(190,43,86,.96);text-align:center;font-size:11px}.offline span{display:inline-block;width:6px;height:6px;margin-right:7px;border-radius:50%;background:#fff}#toast{position:fixed;z-index:30;left:50%;bottom:93px;transform:translateX(-50%) translateY(18px);padding:11px 18px;border:1px solid rgba(255,255,255,.12);border-radius:18px;background:rgba(38,13,55,.96);box-shadow:0 12px 30px rgba(0,0,0,.3);opacity:0;transition:.22s;white-space:nowrap;font-size:12px}#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}@media(max-width:360px){.topbar{padding-left:15px;padding-right:15px}.page{padding-left:14px;padding-right:14px}.hero-card{padding:17px}.album{flex-basis:78px;width:78px;height:78px}.hero-copy h2{font-size:21px}.controls{gap:9px}.song{gap:8px}.song button{padding:0 9px}} + +/* Mobile song search and hot-ranking additions. */ +.search-mode{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:10px;padding:5px;border-radius:16px;background:rgba(9,1,18,.38);border:1px solid rgba(255,255,255,.06)} +.search-mode button{height:40px;border-radius:12px;background:transparent;color:#a898b7;font-size:13px;font-weight:700} +.search-mode button.active{color:#35123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);box-shadow:0 7px 18px rgba(255,184,74,.18)} +.search-result-block{margin-top:24px} +.search-result-block .list{margin-top:12px} +.hot-block{padding-top:22px;margin-top:25px;border-top:1px solid rgba(255,255,255,.1)} +.ranking-list .song-index{font-weight:800} +.ranking-list .song.podium .song-index{color:#4b183e;background:linear-gradient(135deg,#ffe48c,#ffae48)} +.rank-pager{display:grid;grid-template-columns:1fr auto 1fr;gap:12px;align-items:center;margin-top:16px} +.rank-pager button{height:43px;border-radius:14px;background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.11);color:#f0e6f6;font-size:12px;font-weight:700} +.rank-pager button:last-child{background:linear-gradient(135deg,#a74df5,#7130d5)} +.rank-pager button:disabled{opacity:.35;box-shadow:none} +.rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px} +.empty.compact{padding:42px 15px} diff --git a/app/src/main/assets/mobile/app.js b/app/src/main/assets/mobile/app.js new file mode 100644 index 0000000..cb029b8 --- /dev/null +++ b/app/src/main/assets/mobile/app.js @@ -0,0 +1,213 @@ +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => document.querySelectorAll(selector); +const uiState = { + page: 1, query: "", searchMode: "singer", busy: false, + hotPage: 1, hotPageSize: 10, hotBusy: false, + visible: true, connected: false +}; + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => ({ + "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" + }[char])); +} + +function setConnected(connected) { + uiState.connected = connected; + $("#offline").classList.toggle("hidden", connected); + $("#connection").innerHTML = connected + ? "已连接麦动 KTV" + : "正在连接麦动 KTV…"; +} + +async function api(path, options = {}) { + const response = await fetch(path, { + cache: "no-store", + headers: { "Content-Type": "application/json" }, + ...options + }); + const data = await response.json(); + if (!response.ok || data.ok === false) throw new Error(data.error || "操作失败"); + setConnected(true); + return data; +} + +function toast(text) { + const element = $("#toast"); + element.textContent = text; + element.classList.add("show"); + window.setTimeout(() => element.classList.remove("show"), 1600); +} + +function songRow(song, queue = false, current = false, index = 0, ranking = false) { + const number = current ? "♪" : String(index + 1).padStart(2, "0"); + const actions = queue && !current + ? '' + : !queue ? '' : ""; + return `
+
${number}
+
${current ? '正在播放' : ""}${escapeHtml(song.title)}

${escapeHtml(song.singer)}${song.language ? " · " + escapeHtml(song.language) : ""}

+ ${actions} +
`; +} + +function openPage(name) { + $$("nav button,.page").forEach((element) => element.classList.remove("active")); + $(`nav button[data-page="${name}"]`)?.classList.add("active"); + $("#" + name)?.classList.add("active"); + if (name === "queue") loadQueue(); + if (name === "search" && !$("#hotList").children.length) loadHot(1); +} + +async function refresh() { + try { + const data = await api("/api/v1/state"); + const current = data.current || {}; + const count = data.queueSize || 0; + $("#nowTitle").textContent = current.title || "暂无歌曲"; + $("#nowSinger").textContent = current.singer || "请先点一首喜欢的歌"; + $("#queueBadge b").textContent = count; + $("#navCount").textContent = count; + $("#navCount").classList.toggle("hidden", count === 0); + $("#playButton span:last-child").textContent = data.playing ? "暂停" : "继续"; + $("#playButton").dataset.action = data.playing ? "pause" : "resume"; + $("#playButton .control-icon").textContent = data.playing ? "Ⅱ" : "▶"; + $("#musicVolume").value = data.musicVolume ?? 70; + $("#micVolume").value = data.micVolume ?? 70; + $("#musicValue").textContent = data.musicVolume ?? 70; + $("#micValue").textContent = data.micVolume ?? 70; + $("#original").classList.toggle("active", data.vocalMode === "original"); + $("#accompany").classList.toggle("active", data.vocalMode !== "original"); + $("#nextSong").textContent = data.next?.title || "队列为空"; + } catch (_) { + setConnected(false); + } +} + +async function playerAction(action) { + try { + await api("/api/v1/player/actions", { method: "POST", body: JSON.stringify({ action }) }); + await refresh(); + } catch (error) { toast(error.message); } +} + +async function updateAudio(values) { + try { + await api("/api/v1/player/audio", { method: "PATCH", body: JSON.stringify(values) }); + await refresh(); + } catch (error) { toast(error.message); } +} + +async function search(reset = true) { + if (uiState.busy) return; + if (reset) { + uiState.page = 1; + uiState.query = $("#query").value.trim(); + if (!uiState.query) { + toast(`请输入${uiState.searchMode === "singer" ? "歌手" : "歌曲"}名称`); + $("#query").focus(); + return; + } + $("#results").innerHTML = ""; + $("#searchResultBlock").classList.remove("hidden"); + $("#resultTitle").textContent = uiState.searchMode === "singer" ? "歌手搜索结果" : "歌曲搜索结果"; + $("#resultSummary").textContent = `正在搜索“${uiState.query}”`; + } + uiState.busy = true; + try { + const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${uiState.page}&pageSize=20`); + const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (uiState.page - 1) * 20 + index)).join(""); + if (rows) $("#results").insertAdjacentHTML("beforeend", rows); + else if (reset) $("#results").innerHTML = '
没有找到相关歌曲
请更换关键词后重试
'; + $("#resultSummary").textContent = `“${uiState.query}”${data.hasMore ? ",还有更多" : ""}`; + $("#more").classList.toggle("hidden", !data.hasMore); + uiState.page += 1; + } catch (error) { toast(error.message); } + finally { uiState.busy = false; } +} + +async function loadHot(page) { + if (uiState.hotBusy || page < 1) return; + uiState.hotBusy = true; + $("#hotPrev").disabled = true; + $("#hotNext").disabled = true; + $("#hotSummary").textContent = "正在加载…"; + try { + const data = await api(`/api/v1/hot?page=${page}&pageSize=${uiState.hotPageSize}`); + uiState.hotPage = data.page || page; + const songs = data.songs || []; + $("#hotList").innerHTML = songs.map((song, index) => + songRow(song, false, false, (uiState.hotPage - 1) * uiState.hotPageSize + index, true) + ).join("") || '
暂无热歌排行
'; + $("#hotPage").textContent = `第 ${uiState.hotPage} 页`; + $("#hotSummary").textContent = songs.length ? `第 ${(uiState.hotPage - 1) * uiState.hotPageSize + 1}-${(uiState.hotPage - 1) * uiState.hotPageSize + songs.length} 名` : "暂无数据"; + $("#hotPrev").disabled = !data.hasPrevious; + $("#hotNext").disabled = !data.hasMore; + if (page > 1) $("#hot").scrollIntoView({ behavior: "smooth", block: "start" }); + } catch (error) { + $("#hotList").innerHTML = '
热歌排行加载失败
'; + $("#hotSummary").textContent = "请点击翻页重试"; + $("#hotPrev").disabled = uiState.hotPage <= 1; + $("#hotNext").disabled = false; + toast(error.message); + } finally { uiState.hotBusy = false; } +} + +async function loadQueue() { + try { + const data = await api("/api/v1/queue"); + const queue = data.queue || []; + $("#queueCount").textContent = `${queue.length} 首`; + $("#queueList").innerHTML = queue.map((song, index) => songRow(song, true, index === 0, index)).join("") || '
还没有点歌
去曲库选一首喜欢的歌吧
'; + } catch (error) { toast(error.message); } +} + +document.addEventListener("click", async (event) => { + const button = event.target.closest("button"); + if (!button) return; + if (button.dataset.page) openPage(button.dataset.page); + if (button.dataset.action) playerAction(button.dataset.action); + if (button.dataset.vocal) updateAudio({ vocalMode: button.dataset.vocal }); + if (button.dataset.searchMode) { + uiState.searchMode = button.dataset.searchMode; + $$('[data-search-mode]').forEach((item) => item.classList.toggle("active", item === button)); + $("#query").placeholder = uiState.searchMode === "singer" ? "输入歌手名或拼音" : "输入歌曲名或拼音"; + $("#query").value = ""; + $("#results").innerHTML = ""; + $("#searchResultBlock").classList.add("hidden"); + $("#more").classList.add("hidden"); + $("#query").focus(); + } + const row = button.closest(".song"); + if (row && button.classList.contains("order")) { + try { + await api("/api/v1/queue", { method: "POST", body: JSON.stringify({ songId: row.dataset.id }) }); + button.textContent = "已点"; button.disabled = true; toast("已加入歌单"); refresh(); + } catch (error) { toast(error.message); } + } + if (row && button.classList.contains("top")) { + try { + await api("/api/v1/queue/" + encodeURIComponent(row.dataset.id), { method: "PATCH", body: "{}" }); + toast("已置顶为下一首"); loadQueue(); + } catch (error) { toast(error.message); } + } + if (row && button.classList.contains("delete")) { + try { + await api("/api/v1/queue/" + encodeURIComponent(row.dataset.id), { method: "DELETE" }); + toast("已从歌单移除"); loadQueue(); refresh(); + } catch (error) { toast(error.message); } + } +}); + +$("#searchForm").addEventListener("submit", (event) => { event.preventDefault(); search(); }); +$("#more").addEventListener("click", () => search(false)); +$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1)); +$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1)); +["music", "mic"].forEach((kind) => { + $("#" + kind + "Volume").addEventListener("input", (event) => { $("#" + kind + "Value").textContent = event.target.value; }); + $("#" + kind + "Volume").addEventListener("change", (event) => updateAudio({ [kind + "Volume"]: Number(event.target.value) })); +}); +document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); }); +window.setInterval(() => { if (uiState.visible) refresh(); }, 1000); +refresh(); +loadHot(1); diff --git a/app/src/main/assets/mobile/index.html b/app/src/main/assets/mobile/index.html new file mode 100644 index 0000000..7bcbaa4 --- /dev/null +++ b/app/src/main/assets/mobile/index.html @@ -0,0 +1,75 @@ + + + + + + + 麦动 KTV 手机点歌 + + + + +
+ +
+
+
+
麦动 KTV
+

麦动 KTV

正在连接麦动 KTV…

+
+ +
+ +
+
+
+
+
正在播放

暂无歌曲

请先点一首喜欢的歌

+
+ +
+ + + +
+ +
+
声音控制
即时生效
+
+ + +
+ +
02
下一首队列为空
+
+ + + +
+

PLAY QUEUE

已点歌单

0 首
+
i置顶歌曲将排在当前歌曲之后
+
+
+ + +
+
+ + + diff --git a/app/src/main/assets/mobile/ktv_api.js b/app/src/main/assets/mobile/ktv_api.js new file mode 100644 index 0000000..5594353 --- /dev/null +++ b/app/src/main/assets/mobile/ktv_api.js @@ -0,0 +1,384 @@ +/** + * KTV Song API - JS Hot Update Module + * ==================================== + * 从 Gitee 热更新, App 无需发版即可修复 API 变更 + * + * 用法: + * const api = new KtvApi({proxy: 'http://127.0.0.1:7897'}) + * const url = await api.getSongUrl('7678785') + * // → http://download.origjoy.com/E/ts/.../7678785.ts?sign=... + */ + +(function(global) { + 'use strict'; + + // KTV_BRIDGE_API: 7 (Android 缓存校验标记,请勿删除) + + // ─── 配置 ────────────────────────────────────────── + const CONFIG = { + APP_ID: 'd4eeacc6cec3434fbc8c41608a3056a0', + APP_KEY: '024210cba40d4385a93e6c2d3249bfb5', + SDK_KEY: '19042303a8374f67ae3fe1e25c97936f', + VN: '4.100.5.11121504', + VER: '4', + // 歌曲源: 仅 w.w345.my(e.ac19.cn 同后台的另一入口, 无 api.php 登记端点)。 + HOSTS: ['http://w.w345.my'], + HOST: 'http://w.w345.my', + MWS: 'https://mws.cherryonline.cn', + RSA_PUBKEY: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB' + }; + + // ─── 工具函数 ────────────────────────────────────── + function randomHex(n) { + var s = ''; while (n--) s += '0123456789abcdef'[Math.random()*16|0]; return s; + } + + // ─── MD5 ─────────────────────────────────────────── + function md5(input) { + function add(a, b) { + var low = (a & 0xffff) + (b & 0xffff); + var high = (a >>> 16) + (b >>> 16) + (low >>> 16); + return (high << 16) | (low & 0xffff); + } + function rol(value, shift) { return (value << shift) | (value >>> (32 - shift)); } + function wordHex(value) { + var out = ''; + for (var i = 0; i < 4; i++) out += ('0' + ((value >>> (i * 8)) & 255).toString(16)).slice(-2); + return out; + } + + // MD5 works on UTF-8 bytes, then appends 0x80 and the original bit length. + var utf8 = unescape(encodeURIComponent(input)); + var bytes = []; + for (var i = 0; i < utf8.length; i++) bytes.push(utf8.charCodeAt(i)); + var bitLengthLow = (bytes.length << 3) >>> 0; + var bitLengthHigh = (bytes.length >>> 29) >>> 0; + bytes.push(0x80); + while ((bytes.length % 64) !== 56) bytes.push(0); + for (i = 0; i < 4; i++) bytes.push((bitLengthLow >>> (i * 8)) & 255); + for (i = 0; i < 4; i++) bytes.push((bitLengthHigh >>> (i * 8)) & 255); + + var shifts = [ + 7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22, + 5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20, + 4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23, + 6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21 + ]; + var constants = []; + for (i = 0; i < 64; i++) constants[i] = (Math.abs(Math.sin(i + 1)) * 4294967296) | 0; + var a0 = 0x67452301, b0 = 0xefcdab89 | 0, c0 = 0x98badcfe | 0, d0 = 0x10325476; + + for (var offset = 0; offset < bytes.length; offset += 64) { + var words = []; + for (i = 0; i < 16; i++) { + var at = offset + i * 4; + words[i] = bytes[at] | (bytes[at+1] << 8) | (bytes[at+2] << 16) | (bytes[at+3] << 24); + } + var a = a0, b = b0, c = c0, d = d0; + for (i = 0; i < 64; i++) { + var f, g; + if (i < 16) { f = (b & c) | ((~b) & d); g = i; } + else if (i < 32) { f = (d & b) | ((~d) & c); g = (5 * i + 1) % 16; } + else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; } + else { f = c ^ (b | (~d)); g = (7 * i) % 16; } + var oldD = d; + d = c; + c = b; + b = add(b, rol(add(add(a, f), add(constants[i], words[g])), shifts[i])); + a = oldD; + } + a0 = add(a0, a); b0 = add(b0, b); c0 = add(c0, c); d0 = add(d0, d); + } + return wordHex(a0) + wordHex(b0) + wordHex(c0) + wordHex(d0); + } + + // ─── RSA Encrypt ─────────────────────────────────── + async function rsaEncrypt(plaintext) { + if (global.android && typeof global.android.rsaEncryptPkcs1 === 'function') { + var encrypted = global.android.rsaEncryptPkcs1(CONFIG.RSA_PUBKEY, plaintext); + if (!encrypted) throw new Error('native RSA encryption failed'); + return encrypted; + } + throw new Error('RSA/PKCS1 bridge is unavailable'); + } + + // ─── HTTP ────────────────────────────────────────── + function sleep(ms) { + return new Promise(function(resolve) { setTimeout(resolve, ms); }); + } + + function nativeResponse(raw) { + var value = JSON.parse(raw || '{}'); + if (value.error) throw new Error(value.error); + return {body: value.body || '', status: Number(value.status || 0)}; + } + + // 部分节点会在 JSON 前输出 PHP Deprecated HTML,从第一个 "{" 开始容错解析。 + function parseJsonBody(body) { + body = String(body || ''); + var start = body.indexOf('{'); + if (start < 0) throw new Error('JSON object not found'); + return JSON.parse(body.slice(start)); + } + + // 匹配 demo/广告/测试资源 URL 的多种模式: + // 1) wb66 / demo 关键字 (服务端注入广告示例: https://wb66.cn/f/d/mJMSQ/719.ts) + // 2) IPv4 地址 (如 http://154.222.26.244:656/722.ts) + // 3) Cloudflare R2 demo bucket (pub-*.r2.dev/ss/ 通用测试文件) + // 4) 任务4: ts 文件名编号与请求的 musicno 不一致 → 视为广告/错误资源 + // (例: 请求 7018878 却返回 /719.ts 或 /689.ts) + function isDemoUrl(url, musicno) { + var s = String(url || ''); + if (/(?:wb66|demo)/i.test(s)) return true; + if (/^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?\//i.test(s)) return true; + // R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源 + if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true; + // ts 文件名是纯数字, 若与请求的 musicno 不一致则视为广告/测试资源。 + // 原版真源 URL 的 ts 文件名总是等于 musicno (如 /7120742.ts)。 + if (musicno) { + var m = s.match(/\/(\d+)\.ts(?:\?|$)/); + if (m && m[1] !== String(musicno)) return true; + } + return false; + } + + async function httpGet(url, timeout) { + timeout = timeout || 8000; + // 原 APK 的 native GET 抓包只带 Accept: */*,不伪造来源 IP。 + var headers = {'Accept': '*/*'}; + if (global.android && typeof global.android.httpGet === 'function') { + return nativeResponse(global.android.httpGet(url, JSON.stringify(headers), timeout)); + } + return new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.timeout = timeout; + xhr.onload = function() { resolve({body:xhr.responseText, status:xhr.status}); }; + xhr.onerror = function() { reject(new Error('network error')); }; + xhr.ontimeout = function() { reject(new Error('timeout')); }; + xhr.send(); + }); + } + + async function httpPost(url, body, timeout) { + timeout = timeout || 8000; + var headers = {'Content-Type':'application/json', 'Accept':'*/*', 'User-Agent':'Dalvik/2.1.0'}; + if (global.android && typeof global.android.httpPost === 'function') { + return nativeResponse(global.android.httpPost(url, body, JSON.stringify(headers), timeout)); + } + return new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.timeout = timeout; + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = function() { resolve({body:xhr.responseText, status:xhr.status}); }; + xhr.onerror = function() { reject(new Error('network error')); }; + xhr.ontimeout = function() { reject(new Error('timeout')); }; + xhr.send(body); + }); + } + + // ─── KtvApi Class ────────────────────────────────── + function KtvApi(options) { + options = options || {}; + this.proxy = options.proxy || ''; + this.mac = options.mac || randomHex(16); + this.sn = options.sn || randomHex(16); + this.tokens = {}; + // 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。 + this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries)) + ? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3; + // 兼容旧远程脚本/调试代码读取单 token 的行为。 + this.token = ''; + this.debug = options.debug || false; + } + + KtvApi.prototype = { + _log: function(msg) { if (this.debug) console.log('[KtvApi]', msg); }, + + regenerateDevice: function() { + this.mac = randomHex(16); + this.sn = randomHex(16); + this.tokens = {}; + this.token = ''; + this._log('Device regenerated: ' + this.mac + '_' + this.sn); + }, + + // 1. MWS Login + mwsLogin: async function() { + var self = this; + var plaintext = JSON.stringify({device_id: self.mac}); + self._log('MWS login...'); + try { + var encrypted = await rsaEncrypt(plaintext); + var body = JSON.stringify({ + encrypted_data: encrypted, ip: '172.16.32.15', dns: '', router: '', + subnet_mask: '255.255.255.0', channel: 'common', mode: 'ott' + }); + var resp = await httpPost(CONFIG.MWS + '/mls-api/v1/login', body); + if (resp.status === 200) { + var data = parseJsonBody(resp.body); + if (data.authorized) { + self.mac = data.device_id || self.mac; + self.sn = self.mac; + self.tokens = {}; + self.token = ''; + self._log('MWS login OK: ' + self.mac); + return true; + } + } + self._log('MWS login FAIL: ' + resp.status); + return false; + } catch(e) { + self._log('MWS login error: ' + e.message); + return false; + } + }, + + // 2. Get Auth Token + getToken: async function(host, forceRefresh) { + var self = this; + host = host || CONFIG.HOSTS[0]; + if (!forceRefresh && self.tokens[host]) { + self.token = self.tokens[host]; + return self.token; + } + var ts = Math.floor(Date.now() / 1000); + var params = 'appid=' + CONFIG.APP_ID + + '&mac=' + self.mac + '_' + self.sn + + '&sn=' + self.sn + + '&time=' + ts + + '&ver=' + CONFIG.VER + '&vn=' + CONFIG.VN; + var sign = md5(params + CONFIG.APP_KEY); + var url = host + '/i.php?' + params + '&sign=' + sign; + self._log('Getting token: ' + host); + try { + var resp = await httpGet(url); + if (resp.status === 200) { + var data = parseJsonBody(resp.body); + if (data.code === 200) { + self.tokens[host] = data.token; + self.token = data.token; + self._log('Token OK: ' + host); + return self.token; + } + } + delete self.tokens[host]; + self._log('Token FAIL: ' + host + ', HTTP ' + resp.status); + return null; + } catch(e) { + delete self.tokens[host]; + self._log('Token error: ' + host + ', ' + e.message); + return null; + } + }, + + clearToken: function(host) { + delete this.tokens[host]; + this.token = ''; + }, + + _fetchSongUrl: async function(host, musicno, token, ls, resolution, h265) { + var ts = Math.floor(Date.now() / 1000); + // 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 保持一致。 + var params = 'appid=' + CONFIG.APP_ID + + '&device=' + this.mac + '_' + this.sn + + '&ish265=' + (h265 ? '1' : '0') + + '&ls=' + ls + + '&musicno=' + musicno + + '&resolution=' + resolution + + '&sn=' + this.sn + + '&time=' + ts + + '&token=' + token; + var sign = md5(params + CONFIG.SDK_KEY); + var resp = await httpGet(host + '/music/do.php?' + params + '&sign=' + sign); + if (resp.status !== 200) { + return {code: null, data: '', httpStatus: resp.status}; + } + return parseJsonBody(resp.body); + }, + + // 3. Get Song Download URL + // 当前仅使用 w.w345.my(与 e.ac19.cn 同后台的另一入口, 只对 ls=1 返回真源)。 + // 结构保留 host/device 循环, 便于日后恢复多节点/备用源。 + getSongUrl: async function(musicno, resolution, h265, _preferredLs) { + resolution = resolution || '720'; + h265 = !!h265; + var preferredLs = String(_preferredLs == null ? '1' : _preferredLs); + var self = this; + for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) { + if (deviceRound > 0) self.regenerateDevice(); + for (var hostIndex = 0; hostIndex < CONFIG.HOSTS.length; hostIndex++) { + var host = CONFIG.HOSTS[hostIndex]; + self._log((hostIndex === 0 ? 'Primary' : 'Fallback') + + ' host, device round ' + (deviceRound + 1) + ': ' + host); + + var token = await self.getToken(host, false); + if (!token) { self._log('token-fail: ' + host); continue; } + + // w.w345.my 与 e.ac19.cn 同后台, 只试 ls=1(其余 ls 值会被其风控直接拒绝, + // 反复试会放大请求量)。若未来恢复多节点/备用源, 需按节点保留 ls 轮询。 + var lsValues = ['1']; + + for (var lsi = 0; lsi < lsValues.length; lsi++) { + var ls = lsValues[lsi]; + try { + var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265); + // 抓包证实: i.php 下发的 token 恒定不变(端到端同一值, 不随 time/请求变化), + // do.php 返回 403"检测到异常行为"是风控限流, 与 token 过期无关。 + // 原版遇 403 不重取 token, 而是同 token 间隔几秒重试数次后停手(等风控窗口 + // 过去再成功)。因此这里: 403/失败/广告命中时同 token 短退避重试一次, + // 不再清 token 重取 i.php(那会多打一倍请求, 反而放大限流)。 + // 任务4: do.php code=200 但返回广告/错号资源(如 wb66 719.ts)也算失败, + // 退避重试一次, 给服务端机会返回与 musicno 一致的真源。 + var needRetry = data.code !== 200; + if (!needRetry && data.code === 200 && isDemoUrl(data.data || '', musicno)) { + self._log('广告/错号资源 (ls=' + ls + '): ' + String(data.data).slice(0, 90)); + needRetry = true; + } + if (needRetry) { + self._log('do.php code=' + data.code + ' http=' + (data.httpStatus || 0) + + ' for ' + musicno + ' ls=' + ls + '; retry once with same token'); + await sleep(3000); + data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265); + } + + if (data.code === 200) { + var songUrl = data.data || ''; + // 任务4: 广告/错误资源屏蔽——返回的文件编号与请求不一致即视为广告 + // (例: 请求 7018878 却返回 719.ts / wb66 demo 等), 一律丢弃。 + if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) { + self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host); + return songUrl; + } + if (isDemoUrl(songUrl, musicno)) + self._log('仍为广告/错号资源 (ls=' + ls + '): ' + String(songUrl).slice(0, 90)); + // 不是 demo 但也无效 (空/格式不对) → 继续试下一个 ls + } + } catch(e) { + self._log('Failed ls=' + ls + ': ' + host + ', ' + e.message); + } + } + self._log('All ls values exhausted for ' + host); + } + } + return null; + }, + + // 4. Full init sequence + init: async function() { + // token 获取属于每个主/备节点请求的一部分;这里不提前绑定单一节点。 + return true; + } + }; + + // ─── 导出 ────────────────────────────────────────── + global.KtvApi = KtvApi; + global.KtvApiConfig = CONFIG; + global.KtvBridgeApiVersion = 7; + + if (typeof module !== 'undefined' && module.exports) { + module.exports = {KtvApi: KtvApi, CONFIG: CONFIG}; + } + +})(typeof window !== 'undefined' ? window : this); diff --git a/app/src/main/assets/mobile/logo.css b/app/src/main/assets/mobile/logo.css new file mode 100644 index 0000000..a004cae --- /dev/null +++ b/app/src/main/assets/mobile/logo.css @@ -0,0 +1,2 @@ +.brand-mark{padding:0;overflow:hidden;background:#46126d;border:1px solid rgba(255,255,255,.18);box-shadow:0 8px 25px rgba(178,63,255,.28)} +.brand-mark img{display:block;width:100%;height:100%;object-fit:cover} diff --git a/app/src/main/java/com/local/ktv/AccessibilitySupport.kt b/app/src/main/java/com/local/ktv/AccessibilitySupport.kt new file mode 100644 index 0000000..6254871 --- /dev/null +++ b/app/src/main/java/com/local/ktv/AccessibilitySupport.kt @@ -0,0 +1,23 @@ +package com.local.ktv + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView + +internal fun View.setAccessibleFocus(marker: String, spokenLabel: String) { + setTag(R.id.tag_focus_marker, marker) + contentDescription = spokenLabel +} + +internal fun View.focusMarker(): String? = getTag(R.id.tag_focus_marker) as? String + +internal fun ViewGroup.visibleTextLabel(): String = buildList { + fun collect(view: View) { + if (size >= 4 || view.visibility != View.VISIBLE) return + when (view) { + is TextView -> view.text?.toString()?.trim()?.takeIf(String::isNotEmpty)?.let(::add) + is ViewGroup -> repeat(view.childCount) { collect(view.getChildAt(it)) } + } + } + collect(this@visibleTextLabel) +}.distinct().joinToString(",") diff --git a/app/src/main/java/com/local/ktv/AdVideoFilter.kt b/app/src/main/java/com/local/ktv/AdVideoFilter.kt new file mode 100644 index 0000000..fb76b8f --- /dev/null +++ b/app/src/main/java/com/local/ktv/AdVideoFilter.kt @@ -0,0 +1,54 @@ +package com.local.ktv + +import android.util.Log + +/** + * 屏蔽/替换后端注入的贴片广告视频。 + * + * 后端在下发歌曲播放地址时,可能把广告视频(例如 + * `http://gz.ac16.vip/ad_files/my_ad_video.ts`)伪装成歌曲地址返回。 + * 这里统一识别并处理: + * - 默认「屏蔽」:广告地址被拦下后,调用方会回落到数据库中的真实歌曲地址 + * (`SongApiClient` 的 DB fallback),从而直接播放歌曲、跳过广告。 + * - 若需要「替换」成自己的视频,把 [replacementUrl] 设置为目标地址即可。 + */ +object AdVideoFilter { + private const val TAG = "AdVideoFilter" + + /** 命中这些片段的地址视为广告视频(匹配小写)。 */ + private val AD_MARKERS = listOf( + "/ad_files/", + "my_ad_video", + ) + + /** + * 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底); + * 设置后,命中广告时用该地址替换原广告。 + */ + @Volatile + @JvmField + var replacementUrl: String? = null + + /** 判断是否为广告视频地址。 */ + @JvmStatic + fun isAdVideo(url: String?): Boolean { + if (url.isNullOrEmpty()) return false + val lower = url.lowercase() + return AD_MARKERS.any { lower.contains(it) } + } + + /** + * 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换); + * 非广告地址原样返回。 + */ + @JvmStatic + fun resolve(url: String?): String? { + if (!isAdVideo(url)) return url + if (replacementUrl.isNullOrEmpty()) { + Log.w(TAG, "已屏蔽广告视频: $url") + return null + } + Log.i(TAG, "已替换广告视频: $url -> $replacementUrl") + return replacementUrl + } +} \ No newline at end of file diff --git a/app/src/main/java/com/local/ktv/AppPaths.kt b/app/src/main/java/com/local/ktv/AppPaths.kt new file mode 100644 index 0000000..9454cf8 --- /dev/null +++ b/app/src/main/java/com/local/ktv/AppPaths.kt @@ -0,0 +1,35 @@ +package com.local.ktv + +import java.io.File + +/** The only external-storage namespace used by the fresh Maidong KTV app. */ +object AppPaths { + const val ROOT_PATH = "/storage/emulated/0/MaidongKTV" + const val VIDEO_PATH = "$ROOT_PATH/video" + + val root = File(ROOT_PATH) + val stateFile = File(root, "state.json") + val songsDir = File(root, "songs") + val importDir = File(root, "import") + val catalogFile = File(root, "catalog.json") + val recordsDir = File(root, "records") + val moviesDir = File(root, "movies") + val appsDir = File(root, "apps") + val backupDir = File(root, "backup") + val databaseDir = File(root, "database") + val databaseFile = File(databaseDir, "muse.db") + val videoDir = File(VIDEO_PATH) + val cloudSongsDir = File(videoDir, "cloud-song") + + fun ensureDirectories() { + listOf(root, songsDir, importDir, recordsDir, moviesDir, appsDir, backupDir, + databaseDir, videoDir, cloudSongsDir).forEach { + check(it.exists() || it.mkdirs()) { "Cannot create ${it.absolutePath}" } + } + } + + /** The original IJK binary creates this log folder internally; remove it after native startup. */ + fun removeNativeLegacyLogDirectory() { + File("/storage/emulated/0/muse").deleteRecursively() + } +} diff --git a/app/src/main/java/com/local/ktv/AppUpdateManager.kt b/app/src/main/java/com/local/ktv/AppUpdateManager.kt new file mode 100644 index 0000000..cd97a80 --- /dev/null +++ b/app/src/main/java/com/local/ktv/AppUpdateManager.kt @@ -0,0 +1,160 @@ +package com.local.ktv + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +/** Gitee Release based in-app updater. */ +object AppUpdateManager { + private const val LATEST_RELEASE_API = + "https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/latest" + + data class Release( + val version: String, + val title: String, + val notes: String, + val apkUrl: String, + val apkName: String, + ) + + fun check(currentVersion: String, result: (Result) -> Unit) { + Thread({ + result(runCatching { + val json = getText(LATEST_RELEASE_API) + val root = JSONObject(json) + val version = root.optString("tag_name").trim().removePrefix("v") + val assets = root.optJSONArray("assets") + var apkUrl = "" + var apkName = "" + if (assets != null) { + for (index in 0 until assets.length()) { + val asset = assets.optJSONObject(index) ?: continue + val name = asset.optString("name") + val url = asset.optString("browser_download_url") + if (name.endsWith(".apk", ignoreCase = true) || url.contains(".apk", ignoreCase = true)) { + apkName = name.ifBlank { "maidong-ktv-$version.apk" } + apkUrl = url + break + } + } + } + if (version.isBlank() || apkUrl.isBlank() || compareVersions(version, currentVersion) <= 0) null + else Release( + version = version, + title = root.optString("name").ifBlank { "麦动 KTV $version" }, + notes = root.optString("body"), + apkUrl = apkUrl, + apkName = apkName, + ) + }) + }, "gitee-update-check").start() + } + + fun download( + activity: Activity, + release: Release, + progress: (Int) -> Unit, + result: (Result) -> Unit, + ) { + Thread({ + result(runCatching { + val directory = File(activity.externalCacheDir ?: activity.cacheDir, "updates").apply { mkdirs() } + val target = File(directory, "maidong-ktv-${release.version}.apk") + val partial = File(directory, "${target.name}.download") + partial.delete() + val connection = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 30_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}") + } + try { + require(connection.responseCode in 200..299) { "下载失败:HTTP ${connection.responseCode}" } + val total = connection.contentLengthLong + connection.inputStream.use { input -> + FileOutputStream(partial).use { output -> + val buffer = ByteArray(128 * 1024) + var downloaded = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + downloaded += count + if (total > 0) progress((downloaded * 100 / total).toInt().coerceIn(0, 100)) + } + output.fd.sync() + } + } + } finally { + connection.disconnect() + } + require(partial.length() > 1_000_000L) { "下载的安装包不完整" } + partial.inputStream().use { input -> + require(input.read() == 'P'.code && input.read() == 'K'.code) { "下载内容不是 APK" } + } + val info = activity.packageManager.getPackageArchiveInfo(partial.absolutePath, 0) + require(info?.packageName == activity.packageName) { "安装包应用标识不匹配" } + target.delete() + require(partial.renameTo(target)) { "无法保存安装包" } + target + }) + }, "gitee-update-download").start() + } + + /** + * 直接启动系统 Package Installer 安装 APK。 + * + * Android 8+ 不再手动跳转 Settings 权限列表 (TV 遥控器难以操作), + * 而是把 APK URI 交给系统安装器; 若缺少"允许安装未知应用"权限, + * 系统安装器会自动弹出授权对话框, 用户确认后即可继续安装。 + */ + fun install(activity: Activity, apk: File) { + val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", apk) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // Android 7+ 允许系统安装器直接拿到 URI 权限, 无需 NEW_TASK + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + activity.startActivity(intent) + } + + internal fun compareVersions(left: String, right: String): Int { + fun parts(value: String) = value.removePrefix("v").split(Regex("[^0-9]+")) + .filter(String::isNotEmpty).map { it.toIntOrNull() ?: 0 } + val a = parts(left) + val b = parts(right) + for (index in 0 until maxOf(a.size, b.size)) { + val compared = (a.getOrNull(index) ?: 0).compareTo(b.getOrNull(index) ?: 0) + if (compared != 0) return compared + } + return 0 + } + + private fun getText(url: String): String { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 15_000 + instanceFollowRedirects = true + setRequestProperty("Accept", "application/json") + setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}") + } + return try { + require(connection.responseCode == HttpURLConnection.HTTP_OK) { + "检查更新失败:HTTP ${connection.responseCode}" + } + connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + } finally { + connection.disconnect() + } + } +} diff --git a/app/src/main/java/com/local/ktv/BootReceiver.kt b/app/src/main/java/com/local/ktv/BootReceiver.kt new file mode 100644 index 0000000..d197829 --- /dev/null +++ b/app/src/main/java/com/local/ktv/BootReceiver.kt @@ -0,0 +1,22 @@ +package com.local.ktv + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return + runCatching { + context.startActivity( + Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + }.onFailure { Log.e(TAG, "Unable to launch KTV after boot", it) } + } + + private companion object { + const val TAG = "BootReceiver" + } +} diff --git a/app/src/main/java/com/local/ktv/CatalogClient.kt b/app/src/main/java/com/local/ktv/CatalogClient.kt new file mode 100644 index 0000000..3c89564 --- /dev/null +++ b/app/src/main/java/com/local/ktv/CatalogClient.kt @@ -0,0 +1,26 @@ +package com.local.ktv + +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +class CatalogClient { + fun fetch(url: String): JSONArray { + val body = (URL(url).openConnection() as HttpURLConnection).run { + connectTimeout = 10_000 + readTimeout = 15_000 + requestMethod = "GET" + try { + val status = responseCode + check(status in 200..299) { "HTTP $status" } + inputStream.buffered().reader(Charsets.UTF_8).use { it.readText() } + } finally { + disconnect() + } + }.trim() + if (body.startsWith("[")) return JSONArray(body) + val root = JSONObject(body) + return root.optJSONArray("songs") ?: root.getJSONArray("data") + } +} diff --git a/app/src/main/java/com/local/ktv/CircularVolumeView.kt b/app/src/main/java/com/local/ktv/CircularVolumeView.kt new file mode 100644 index 0000000..a9e7455 --- /dev/null +++ b/app/src/main/java/com/local/ktv/CircularVolumeView.kt @@ -0,0 +1,71 @@ +package com.local.ktv + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.View +import kotlin.math.min + +/** Full-screen volume indicator modelled after the original KTV circular overlay. */ +class CircularVolumeView(context: Context) : View(context) { + private val density = resources.displayMetrics.density + private val arcBounds = RectF() + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private var volume = 0 + + fun setVolume(value: Int) { + volume = value.coerceIn(0, 100) + contentDescription = "音量 $volume" + invalidate() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val desired = (154f * density).toInt() + val width = resolveSize(desired, widthMeasureSpec) + val height = resolveSize(desired, heightMeasureSpec) + val size = min(width, height) + setMeasuredDimension(size, size) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val size = min(width, height).toFloat() + val center = size / 2f + val outerWidth = 12f * density + val progressWidth = 12f * density + val radius = center - outerWidth + + paint.style = Paint.Style.FILL + paint.color = Color.rgb(48, 39, 102) + canvas.drawCircle(center, center, radius - progressWidth * 0.15f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeCap = Paint.Cap.BUTT + paint.strokeWidth = outerWidth + paint.color = Color.rgb(47, 37, 106) + canvas.drawCircle(center, center, radius, paint) + + val ringRadius = radius - outerWidth * 0.52f + arcBounds.set( + center - ringRadius, + center - ringRadius, + center + ringRadius, + center + ringRadius, + ) + paint.strokeWidth = progressWidth + paint.color = Color.rgb(60, 51, 111) + canvas.drawArc(arcBounds, -90f, 360f, false, paint) + paint.color = Color.rgb(151, 155, 143) + canvas.drawArc(arcBounds, -90f, volume * 3.6f, false, paint) + + paint.style = Paint.Style.FILL + paint.textAlign = Paint.Align.CENTER + paint.color = Color.rgb(220, 220, 214) + paint.textSize = 30f * density + canvas.drawText(volume.toString(), center, center - 3f * density, paint) + paint.textSize = 20f * density + canvas.drawText("音量", center, center + 38f * density, paint) + } +} diff --git a/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt b/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt new file mode 100644 index 0000000..127f490 --- /dev/null +++ b/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt @@ -0,0 +1,63 @@ +package com.local.ktv + +import android.util.Log +import java.io.File +import java.util.Locale + +/** + * 曲库下载引导(仅官方 Muse 同步源)。 + * + * 说明:已移除 Gitee 数据库分片/镜像下载路径(database_publish 不再发布到 Gitee, + * Gitee 仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api) + * 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类只做版本文件与可读错误包装。 + */ +object DatabaseBootstrapper { + private const val TAG = "DatabaseBootstrapper" + + private val versionFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_version.txt") + + /** 远端曲库版本(官方 Muse);失败返回 null。 */ + fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion() + + fun getLocalDbVersion(): String? = runCatching { + versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() } + }.getOrNull() + + private fun saveLocalDbVersion(version: String) { + runCatching { versionFile.writeText(version.trim()) } + } + + /** 下载曲库:仅官方 Muse 同步源。 */ + fun download(onProgress: (Int) -> Unit): Result { + return runCatching { + val file = MuseDbSync.download(onProgress) + // MuseDbSync 内部已保存版本;此处幂等兜底。 + MuseDbSync.fetchVersion()?.let(::saveLocalDbVersion) + file + }.mapError(::readableError) + } + + private inline fun Result.mapError(transform: (Throwable) -> Throwable): Result = + fold(onSuccess = { Result.success(it) }, onFailure = { Result.failure(transform(it)) }) + + private fun formatSize(bytes: Long): String = + String.format(Locale.ROOT, "%.2f GB", bytes / 1024.0 / 1024.0 / 1024.0) + + private fun isNoSpaceError(error: Throwable?): Boolean = generateSequence(error) { it.cause } + .mapNotNull { it.message?.lowercase(Locale.ROOT) } + .any { "enospc" in it || "no space left" in it } + + private fun readableError(error: Throwable): Throwable { + if (isNoSpaceError(error)) { + val target = MuseDatabase.defaultDbFile() + return IllegalStateException( + "曲库分区空间不足(当前可用 ${formatSize(target.parentFile?.usableSpace ?: 0L)})," + + "已清理下载临时文件,请释放空间后重试", + error, + ) + } + val message = error.message?.takeIf { it.isNotBlank() } ?: "曲库下载失败" + Log.w(TAG, "Database download failed: $message") + return IllegalStateException(message, error) + } +} diff --git a/app/src/main/java/com/local/ktv/DownloadTask.kt b/app/src/main/java/com/local/ktv/DownloadTask.kt new file mode 100644 index 0000000..1860bae --- /dev/null +++ b/app/src/main/java/com/local/ktv/DownloadTask.kt @@ -0,0 +1,12 @@ +package com.local.ktv + +class DownloadTask(@JvmField val song: Song) { + @JvmField var state: String = "等待" + @JvmField var progress: Int = 0 + @JvmField var cancelled: Boolean = false + + fun display(): String = buildString { + append(song.title).append(" - ").append(song.singer).append(" ").append(state) + if (progress > 0) append(' ').append(progress).append('%') + } +} diff --git a/app/src/main/java/com/local/ktv/Eac19WindowGuard.kt b/app/src/main/java/com/local/ktv/Eac19WindowGuard.kt new file mode 100644 index 0000000..9a3f023 --- /dev/null +++ b/app/src/main/java/com/local/ktv/Eac19WindowGuard.kt @@ -0,0 +1,57 @@ +package com.local.ktv + +import android.util.Log + +/** + * 歌曲源(w.w345.my)"试用窗口"守卫(最小版)。 + * + * 抓包/实测结论: 歌曲源对陌生出口 IP 发放约 10 分钟试用窗口 + * (api.php: trial_times:2 × trial_duration:5), do.php 在该窗口外返回 + * 403"检测到异常行为", 换公网 IP 后立即恢复。额度记在出口 IP 上, + * 与设备无关(同 IP 换 device 无效)。 + * + * 职责: + * - 收到 do.php 风控 403 信号时通知 UI 弹一次提示(引导重启光猫/路由器换 IP); + * - 取 URL 成功(窗口恢复)后自动复位, 下一次到期可再次提示; + * - 全程不阻塞、不影响播放(本地已缓存歌曲照常播)。 + */ +object Eac19WindowGuard { + private const val TAG = "Eac19WindowGuard" + + /** UI 层订阅: 窗口到期(风控 403)时回调(已在主线程)。 */ + @Volatile var onBlocked: (() -> Unit)? = null + + /** 距离上次提示的最小间隔, 防止连续失败时反复弹窗。 */ + private const val MIN_PROMPT_INTERVAL_MS = 15 * 60_000L + + private val lock = Any() + private var lastPromptAt = 0L + private var blockedEpisodeActive = false + + /** 由 JS 桥在 do.php 返回风控 403 时调用(任意线程)。 */ + fun noteRiskBlocked() { + val now = System.currentTimeMillis() + val shouldNotify: Boolean + synchronized(lock) { + if (!blockedEpisodeActive) { + blockedEpisodeActive = true + lastPromptAt = now + shouldNotify = true + } else { + shouldNotify = now - lastPromptAt >= MIN_PROMPT_INTERVAL_MS + if (shouldNotify) lastPromptAt = now + } + } + if (shouldNotify) { + Log.w(TAG, "歌曲源 risk-control 403 (trial window expired)") + onBlocked?.invoke() + } + } + + /** 取到有效歌曲 URL(窗口恢复)时调用, 复位本次到期 episode。 */ + fun noteUrlOk() { + synchronized(lock) { + blockedEpisodeActive = false + } + } +} diff --git a/app/src/main/java/com/local/ktv/KtvApplication.kt b/app/src/main/java/com/local/ktv/KtvApplication.kt new file mode 100644 index 0000000..685f4ed --- /dev/null +++ b/app/src/main/java/com/local/ktv/KtvApplication.kt @@ -0,0 +1,24 @@ +package com.local.ktv + +import android.app.Application +import android.util.Log +import com.liulishuo.okdownload.OkDownload + +class KtvApplication : Application() { + override fun onCreate() { + super.onCreate() + instanceRef = this + OkDownload.setSingletonInstance(OkDownload.Builder(this).build()) + Log.i(TAG, "KTV application initialized without native services") + } + + companion object { + private const val TAG = "KtvApplication" + + lateinit var instanceRef: KtvApplication + private set + + @JvmStatic + fun getInstance(): KtvApplication = instanceRef + } +} diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt new file mode 100644 index 0000000..893742f --- /dev/null +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -0,0 +1,470 @@ +package com.local.ktv + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.Socket +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.Locale +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import java.security.KeyFactory +import java.security.spec.X509EncodedKeySpec +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.crypto.Cipher + +/** + * 在隐藏 WebView 中运行可热更新的歌曲接口脚本。 + * + * JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个 + * WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitee 上的 ktv_api.js。 + */ +class KtvJsBridge(context: Context) { + companion object { + private const val TAG = "KtvJsBridge" + private const val JS_FILE = "mobile/ktv_api.js" + private const val CACHE_FILE = "ktv_api_cache.js" + private const val REMOTE_JS_URL = + "https://gitee.com/zijunchiang/maidong-ktv/raw/master/app/src/main/assets/mobile/ktv_api.js" + private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024 + private const val JS_REQUEST_TIMEOUT_MS = 180_000 + // 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致, + // w.w345.my 与 e.ac19.cn 同后台,按 device 识别; 随机/变化的 device 会被风控。 + private const val DEVICE_MAC = "ae60e9547958" + private const val DEVICE_SN = "2d7bb4a7d7f7" + } + + private val appContext = context.applicationContext + private val mainHandler = Handler(Looper.getMainLooper()) + private val callbacks = ConcurrentHashMap Unit>() + + @Volatile private var webView: WebView? = null + @Volatile private var ready = false + @Volatile private var destroyed = false + private var loadedFromCache = false + private var assetRetryAttempted = false + + /** 必须最终在主线程创建 WebView;可从任意线程调用。 */ + fun init(callback: (Boolean) -> Unit) { + mainHandler.post { + if (destroyed) { + callback(false) + return@post + } + try { + val cached = File(appContext.filesDir, CACHE_FILE) + val cachedScript = cached.takeIf(::isValidScript)?.readText(Charsets.UTF_8) + loadedFromCache = cachedScript != null + createWebView(cachedScript ?: readAssetScript(), callback) + updateCache(cached) + } catch (error: Throwable) { + Log.e(TAG, "WebView init failed", error) + callback(false) + } + } + } + + private fun createWebView(script: String, callback: (Boolean) -> Unit) { + webView?.destroy() + ready = false + webView = WebView(appContext).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + addJavascriptInterface(NativeBridge(), "android") + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + view.evaluateJavascript("typeof window.KtvApi === 'function'") { result -> + if (result == "true") { + ready = true + Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})") + callback(true) + } else if (loadedFromCache && !assetRetryAttempted) { + Log.w(TAG, "Cached JS failed to load; falling back to packaged asset") + assetRetryAttempted = true + loadedFromCache = false + createWebView(readAssetScript(), callback) + } else { + Log.e(TAG, "KtvApi was not exported by JS") + callback(false) + } + } + } + } + loadDataWithBaseURL("https://localhost/", wrapHtml(script), "text/html", "UTF-8", null) + } + } + + /** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */ + fun isReady(): Boolean = ready && !destroyed + + fun isDestroyed(): Boolean = destroyed + + fun getSongUrl( + musicNo: String, + resolution: String, + h265: Boolean, + callback: (String?) -> Unit, + ) { + if (!ready || destroyed) { + callback(null) + return + } + val requestId = UUID.randomUUID().toString() + callbacks[requestId] = callback + val script = """ + (function() { + var requestId = ${JSONObject.quote(requestId)}; + var completed = false; + function finish(url) { + if (completed) return; + completed = true; + clearTimeout(timer); + android.onUrl(requestId, url || ''); + } + var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS); + Promise.resolve().then(async function() { + if (!window.api) { + // 歌曲源按 device 识别:随机/每次更换的 device 会被判"异常行为"。 + // 固定使用抓包注册设备的 mac+sn(与抓包请求一致),并保持版本号一致。 + if (window.KtvApiConfig) { + window.KtvApiConfig.VER = '2.0'; + window.KtvApiConfig.VN = '4.1.3.03281430'; + } + window.api = new window.KtvApi({mac: ${JSONObject.quote(DEVICE_MAC)}, sn: ${JSONObject.quote(DEVICE_SN)}, maxDeviceRetries: 0, debug: true}); + var _regen = window.api.regenerateDevice; + window.api.regenerateDevice = function() { + _regen.call(window.api); + window.api.mac = ${JSONObject.quote(DEVICE_MAC)}; + window.api.sn = ${JSONObject.quote(DEVICE_SN)}; + }; + } + if (!window._apiInitPromise) { + window._apiInitPromise = window.api.init().then(function(ok) { + if (!ok) throw new Error('API initialization failed'); + return true; + }).catch(function(error) { + window._apiInitPromise = null; + throw error; + }); + } + await window._apiInitPromise; + return window.api.getSongUrl( + ${JSONObject.quote(musicNo)}, ${JSONObject.quote(resolution)}, ${if (h265) "true" else "false"} + ); + }).then(finish).catch(function(error) { + android.log('getSongUrl failed: ' + (error && error.message ? error.message : error)); + finish(''); + }); + })(); + """.trimIndent() + mainHandler.post { + if (!ready) complete(requestId, null) + else webView?.evaluateJavascript(script, null) ?: complete(requestId, null) + } + } + + // JS isDemoUrl 已做第一层正则过滤; 这里再做域名白名单兜底, 防止 demo URL 绕过 + private val trustedHostSuffixes = listOf( + "ktvsky.com", "origjoy.com", "cherryonline.cn", "ac16.vip", + "ktvdaren.com", "j-make.cn", + ) + // 已知 demo 域名模式 (Cloudflare R2 测试桶、IP 地址等) + private val demoHostPatterns = listOf( + Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), // 纯 IP + Regex("^pub-[a-z0-9]+\\.r2\\.dev$"), // R2 demo bucket + ) + + private fun isTrustedDownloadUrl(url: String?): Boolean { + if (url == null) return false + if (!url.startsWith("http://") && !url.startsWith("https://")) return false + val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return false + if (demoHostPatterns.any { it.matches(host) }) { + Log.w(TAG, "Rejected demo URL host: $host") + return false + } + val trusted = trustedHostSuffixes.any { host.endsWith(it) } + if (!trusted) Log.w(TAG, "Untrusted URL host: $host") + return trusted + } + + private fun complete(requestId: String, url: String?) { + // 先屏蔽/替换广告视频,再做域名白名单校验。 + callbacks.remove(requestId)?.invoke(AdVideoFilter.resolve(url)?.takeIf { isTrustedDownloadUrl(it) }) + } + + private fun readAssetScript(): String = + appContext.assets.open(JS_FILE).bufferedReader(Charsets.UTF_8).use { it.readText() } + + private fun isValidScript(file: File): Boolean = + runCatching { + file.isFile && file.length() in 1_001..500_000 && + file.readText(Charsets.UTF_8).let { + it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 7") + } + }.getOrDefault(false) + + /** 下载成功后原子替换缓存;新脚本在下次启动时生效。 */ + private fun updateCache(cacheFile: File) { + Thread({ + val tempFile = File(cacheFile.parentFile, "${cacheFile.name}.tmp") + try { + val connection = (URL(REMOTE_JS_URL).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 15_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", "KTV-Updater/2.0") + } + try { + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw IllegalStateException("HTTP ${connection.responseCode}") + } + val declaredLength = connection.contentLengthLong + require(declaredLength < 0 || declaredLength <= MAX_RESPONSE_BYTES) { "JS file is too large" } + val bytes = connection.inputStream.use { it.readBytes() } + require(bytes.size <= MAX_RESPONSE_BYTES) { "JS file is too large" } + val script = String(bytes, StandardCharsets.UTF_8) + require( + script.length > 1000 && script.contains("KtvApi") && + script.contains("KTV_BRIDGE_API: 7"), + ) { "Invalid or incompatible JS file" } + tempFile.writeText(script, Charsets.UTF_8) + if (!tempFile.renameTo(cacheFile)) { + cacheFile.writeText(script, Charsets.UTF_8) + tempFile.delete() + } + Log.i(TAG, "JS cache updated from Gitee (${script.length} chars)") + // 启动后刚拉到的新版本直接生效;若已有请求在执行,则留到下次启动。 + mainHandler.post { + if (ready && callbacks.isEmpty() && !destroyed) { + loadedFromCache = true + createWebView(script) { ok -> Log.i(TAG, "Hot-reloaded JS: $ok") } + } + } + } finally { + connection.disconnect() + } + } catch (error: Throwable) { + tempFile.delete() + Log.w(TAG, "JS update failed: ${error.message}") + } + }, "ktv-js-updater").start() + } + + private fun wrapHtml(script: String): String { + val escaped = script.replace("" + } + + inner class NativeBridge { + @JavascriptInterface + fun log(message: String) = Log.d(TAG, message) + + @JavascriptInterface + fun onUrl(requestId: String, url: String) { + Log.d(TAG, "URL result ${requestId.take(8)}: ${url.take(100)}") + complete(requestId, url) + } + + @JavascriptInterface + fun rsaEncryptPkcs1(publicKeyBase64: String, plaintext: String): String = + runCatching { + val keyBytes = Base64.decode(publicKeyBase64.replace("\\s".toRegex(), ""), Base64.DEFAULT) + val publicKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(keyBytes)) + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + Base64.encodeToString(cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) + }.onFailure { Log.e(TAG, "RSA encryption failed", it) }.getOrDefault("") + + @JavascriptInterface + fun httpGet(url: String, headersJson: String, timeoutMs: Int): String = + request("GET", url, null, headersJson, timeoutMs) + + @JavascriptInterface + fun httpPost(url: String, body: String, headersJson: String, timeoutMs: Int): String = + request("POST", url, body, headersJson, timeoutMs) + + private fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String { + // 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept; + // 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。 + // 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection + // 补发第二次(限流时补发只会双倍放大请求量,加速被源限流)。 + val raw = rawRequest(method, url, body, headersJson, timeoutMs) + val status = runCatching { JSONObject(raw).optInt("status") }.getOrDefault(0) + if (status > 0) { + // 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。 + if (status == 403 || status >= 500) { + MiliLog.error("HTTP-$status $method $url") + } + // 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为", + // 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。 + if (status == 403 && url.contains("/music/do.php")) { + Eac19WindowGuard.noteRiskBlocked() + } + return raw + } + // 仅当裸 Socket 连接失败(status=0)时回退 HttpURLConnection。 + Log.w(TAG, "raw socket connect failed, falling back to HttpURLConnection: $url") + val fallback = httpRequest(method, url, body, headersJson, timeoutMs) + val fallbackStatus = runCatching { JSONObject(fallback).optInt("status") }.getOrDefault(0) + if (fallbackStatus == 0) { + // 两个通道都失败 → 网络不可达/解析失败, 实时上报。 + val err = runCatching { JSONObject(fallback).optString("error") }.getOrDefault("") + MiliLog.error("NET-ERR $method $url $err") + } + return fallback + } + + /** HttpURLConnection 通道:JS 未指定 UA/编码/连接时改中性值,避免默认头触发风控。 */ + private fun httpRequest(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String { + var connection: HttpURLConnection? = null + return try { + require(url.startsWith("http://") || url.startsWith("https://")) { "Unsupported URL" } + connection = URL(url).openConnection() as HttpURLConnection + connection.requestMethod = method + connection.connectTimeout = timeoutMs.coerceIn(1_000, 30_000) + connection.readTimeout = timeoutMs.coerceIn(1_000, 30_000) + connection.instanceFollowRedirects = true + val headers = JSONObject(headersJson.ifBlank { "{}" }) + headers.keys().forEach { name -> connection.setRequestProperty(name, headers.optString(name)) } + // 歌曲源的 /music/do.php 风控只放行"干净"的请求(抓包只发 Host+Accept): + // 空 UA、Dalvik、okhttp、Chrome 的 UA 都会返回"检测到异常行为",Go UA 可放行。 + // HttpURLConnection 默认会补 Dalvik UA、Accept-Encoding: gzip、Connection: Keep-Alive, + // 这里在 JS 未显式指定时全部改成中性值,尽量贴近原版请求。 + if (!headers.has("User-Agent")) connection.setRequestProperty("User-Agent", "Go-http-client/1.1") + if (!headers.has("Accept-Encoding")) connection.setRequestProperty("Accept-Encoding", "identity") + if (!headers.has("Connection")) connection.setRequestProperty("Connection", "close") + if (body != null) { + connection.doOutput = true + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } + val status = connection.responseCode + val stream = if (status >= 400) connection.errorStream else connection.inputStream + val responseBody = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() + JSONObject().put("status", status).put("body", responseBody).toString() + } catch (error: Throwable) { + Log.w(TAG, "$method request failed: ${error.message}") + JSONObject().put("status", 0).put("body", "").put("error", error.message.orEmpty()).toString() + } finally { + connection?.disconnect() + } + } + + /** 裸 Socket 通道:只发 Host + JS 指定的请求头,不再补任何默认头(UA/Connection/Accept-Encoding)。 */ + private fun rawRequest(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String { + var socket: Socket? = null + return try { + require(url.startsWith("http://") || url.startsWith("https://")) { "Unsupported URL" } + val isHttps = url.startsWith("https://") + val uri = URL(url).toURI() + val port = if (uri.port > 0) uri.port else if (isHttps) 443 else 80 + socket = if (isHttps) { + (SSLSocketFactory.getDefault().createSocket(uri.host, port) as SSLSocket).apply { startHandshake() } + } else { + Socket(uri.host, port) + } + socket.soTimeout = timeoutMs.coerceIn(1_000, 30_000) + val path = if (uri.rawQuery != null) "${uri.rawPath}?${uri.rawQuery}" else uri.rawPath + val requestText = StringBuilder() + requestText.append("$method $path HTTP/1.1\r\n") + requestText.append("Host: ${uri.host}\r\n") + val headers = JSONObject(headersJson.ifBlank { "{}" }) + headers.keys().forEach { name -> requestText.append("$name: ${headers.optString(name)}\r\n") } + val bodyBytes = body?.toByteArray(Charsets.UTF_8) + if (bodyBytes != null) requestText.append("Content-Length: ${bodyBytes.size}\r\n") + requestText.append("\r\n") + val out = socket.getOutputStream() + out.write(requestText.toString().toByteArray(Charsets.UTF_8)) + if (bodyBytes != null) out.write(bodyBytes) + out.flush() + + val input = BufferedInputStream(socket.getInputStream()) + val statusLine = readAsciiLine(input) + val status = statusLine?.split(" ")?.getOrNull(1)?.toIntOrNull() ?: 0 + var contentLength = -1L + var chunked = false + while (true) { + val line = readAsciiLine(input) ?: break + if (line.isEmpty()) break + val idx = line.indexOf(':') + if (idx > 0) { + val name = line.substring(0, idx).trim().lowercase(Locale.ROOT) + val value = line.substring(idx + 1).trim() + if (name == "content-length") contentLength = value.toLongOrNull() ?: -1L + if (name == "transfer-encoding" && value.contains("chunked")) chunked = true + } + } + val responseBody = when { + chunked -> readChunkedBody(input) + contentLength >= 0 -> String(readExact(input, contentLength), Charsets.UTF_8) + else -> input.readBytes().toString(Charsets.UTF_8) + } + JSONObject().put("status", status).put("body", responseBody).toString() + } catch (error: Throwable) { + Log.w(TAG, "raw request failed: ${error.message}") + JSONObject().put("status", 0).put("body", "").put("error", error.message.orEmpty()).toString() + } finally { + try { socket?.close() } catch (ignored: Exception) {} + } + } + + private fun readAsciiLine(input: InputStream): String? { + val sb = StringBuilder() + while (true) { + val b = input.read() + if (b < 0) return if (sb.isEmpty()) null else sb.toString() + if (b == '\n'.code) { + if (sb.isNotEmpty() && sb[sb.length - 1] == '\r') sb.setLength(sb.length - 1) + return sb.toString() + } + sb.append(b.toChar()) + } + } + + private fun readExact(input: InputStream, length: Long): ByteArray { + val result = ByteArray(length.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) + var off = 0 + while (off < result.size) { + val n = input.read(result, off, result.size - off) + if (n < 0) break + off += n + } + return if (off == result.size) result else result.copyOf(off) + } + + private fun readChunkedBody(input: InputStream): String { + val out = ByteArrayOutputStream() + while (true) { + val sizeLine = readAsciiLine(input) ?: break + val size = sizeLine.substringBefore(';').trim().toIntOrNull(16) ?: 0 + if (size <= 0) break + out.write(readExact(input, size.toLong())) + readAsciiLine(input) // 吃掉 chunk 尾部 CRLF + } + return out.toString(Charsets.UTF_8) + } + } + + fun destroy() { + destroyed = true + ready = false + callbacks.keys.toList().forEach { complete(it, null) } + mainHandler.post { + webView?.removeJavascriptInterface("android") + webView?.destroy() + webView = null + } + } +} diff --git a/app/src/main/java/com/local/ktv/KtvStateDatabase.kt b/app/src/main/java/com/local/ktv/KtvStateDatabase.kt new file mode 100644 index 0000000..8e9ebd5 --- /dev/null +++ b/app/src/main/java/com/local/ktv/KtvStateDatabase.kt @@ -0,0 +1,263 @@ +package com.local.ktv + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +class KtvStateDatabase(context: Context) : + SQLiteOpenHelper(context, "ktv_state.db", null, VERSION) { + + data class RestoredState(val currentSong: Song?, val playbackState: String) + + data class DownloadSnapshot( + val song: Song, + val status: String, + val progress: Int, + val localPath: String?, + val error: String?, + ) + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE meta (`key` TEXT PRIMARY KEY, `value` TEXT NOT NULL)") + db.execSQL( + """CREATE TABLE order_media ( + position INTEGER PRIMARY KEY, + stable_id TEXT NOT NULL UNIQUE, + song_json TEXT NOT NULL, + is_current INTEGER NOT NULL DEFAULT 0, + playback_state TEXT NOT NULL DEFAULT 'queued', + updated_at INTEGER NOT NULL + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE sang_history ( + position INTEGER PRIMARY KEY, + stable_id TEXT NOT NULL, + song_json TEXT NOT NULL, + sang_at INTEGER NOT NULL + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE song_clicks ( + stable_id TEXT PRIMARY KEY, + title TEXT, + singer TEXT, + click_count INTEGER NOT NULL DEFAULT 0, + click_at INTEGER NOT NULL + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE download_logs ( + stable_id TEXT PRIMARY KEY, + song_json TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + local_path TEXT, + error TEXT, + updated_at INTEGER NOT NULL + )""".trimIndent(), + ) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + + @Synchronized + fun restoreOrSeed(queue: MutableList, history: MutableList): RestoredState { + val db = writableDatabase + if (!isInitialized(db)) { + db.transaction { + syncQueueInternal(this, queue, history, null, "idle") + insertWithOnConflict( + "meta", + null, + ContentValues().apply { + put("key", "initialized") + put("value", "1") + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + } + return RestoredState(null, "idle") + } + + queue.clear() + history.clear() + var current: Song? = null + var playbackState = "idle" + db.query( + "order_media", + arrayOf("song_json", "is_current", "playback_state"), + null, + null, + null, + null, + "position ASC", + ).use { cursor -> + while (cursor.moveToNext()) { + val song = Song.fromJson(org.json.JSONObject(cursor.getString(0))) + queue += song + if (cursor.getInt(1) == 1) { + current = song + playbackState = cursor.getString(2) + } + } + } + db.query( + "sang_history", + arrayOf("song_json"), + null, + null, + null, + null, + "position ASC", + ).use { cursor -> + while (cursor.moveToNext()) { + history += Song.fromJson(org.json.JSONObject(cursor.getString(0))) + } + } + return RestoredState(current, playbackState) + } + + @Synchronized + fun syncQueue(queue: List, history: List, current: Song?, playbackState: String) { + writableDatabase.transaction { + syncQueueInternal(this, queue, history, current, playbackState) + } + } + + @Synchronized + fun markSongClicked(song: Song) { + val db = writableDatabase + val id = KtvStore.stableId(song) + db.execSQL( + """INSERT OR REPLACE INTO song_clicks(stable_id,title,singer,click_count,click_at) + VALUES(?,?,?,COALESCE((SELECT click_count FROM song_clicks WHERE stable_id=?),0)+1,?)""".trimIndent(), + arrayOf(id, song.title, song.singer, id, System.currentTimeMillis()), + ) + } + + @Synchronized + fun updateDownload( + song: Song, + status: String, + progress: Int, + localPath: String? = null, + error: String? = null, + ) { + writableDatabase.insertWithOnConflict( + "download_logs", + null, + ContentValues().apply { + put("stable_id", KtvStore.stableId(song)) + put("song_json", song.toJson().toString()) + put("status", status) + put("progress", progress.coerceIn(0, 100)) + put("local_path", localPath) + put("error", error) + put("updated_at", System.currentTimeMillis()) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + } + + @Synchronized + fun restoreDownloads(): List = buildList { + readableDatabase.query( + "download_logs", + arrayOf("song_json", "status", "progress", "local_path", "error"), + null, + null, + null, + null, + "updated_at DESC", + ).use { cursor -> + while (cursor.moveToNext()) { + add( + DownloadSnapshot( + Song.fromJson(org.json.JSONObject(cursor.getString(0))), + cursor.getString(1), + cursor.getInt(2), + cursor.getString(3), + cursor.getString(4), + ), + ) + } + } + } + + @Synchronized + fun removeDownload(song: Song) { + writableDatabase.delete("download_logs", "stable_id=?", arrayOf(KtvStore.stableId(song))) + } + + @Synchronized + fun clearDownloads() { + writableDatabase.delete("download_logs", null, null) + } + + private fun syncQueueInternal( + db: SQLiteDatabase, + queue: List, + history: List, + current: Song?, + playbackState: String, + ) { + val now = System.currentTimeMillis() + val currentId = current?.let(KtvStore::stableId) + db.delete("order_media", null, null) + queue.take(200).forEachIndexed { position, song -> + val stableId = KtvStore.stableId(song) + db.insertOrThrow( + "order_media", + null, + ContentValues().apply { + put("position", position) + put("stable_id", stableId) + put("song_json", song.toJson().toString()) + put("is_current", if (stableId == currentId) 1 else 0) + put("playback_state", if (stableId == currentId) playbackState else "queued") + put("updated_at", now) + }, + ) + } + db.delete("sang_history", null, null) + history.take(200).forEachIndexed { position, song -> + db.insertOrThrow( + "sang_history", + null, + ContentValues().apply { + put("position", position) + put("stable_id", KtvStore.stableId(song)) + put("song_json", song.toJson().toString()) + put("sang_at", now) + }, + ) + } + } + + private fun isInitialized(db: SQLiteDatabase): Boolean = db.query( + "meta", + arrayOf("value"), + "`key`=?", + arrayOf("initialized"), + null, + null, + null, + "1", + ).use { it.moveToFirst() } + + private inline fun SQLiteDatabase.transaction(block: SQLiteDatabase.() -> Unit) { + beginTransaction() + try { + block() + setTransactionSuccessful() + } finally { + endTransaction() + } + } + + private companion object { + const val VERSION = 1 + } +} diff --git a/app/src/main/java/com/local/ktv/KtvStore.kt b/app/src/main/java/com/local/ktv/KtvStore.kt new file mode 100644 index 0000000..9c95288 --- /dev/null +++ b/app/src/main/java/com/local/ktv/KtvStore.kt @@ -0,0 +1,226 @@ +package com.local.ktv + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.LinkedHashMap +import java.util.LinkedHashSet + +class KtvStore { + private val file = AppPaths.stateFile + + fun stateFile(): File = file + + @JvmField var loopOne = false + @JvmField var autoNext = true + @JvmField var originalVocal = true + @JvmField var vocalChannelMode = "自动" + @JvmField var musicVolume = 70 + @JvmField var micVolume = 70 + @JvmField var tone = 0 + @JvmField var atmosphere = "标准" + @JvmField var singMode = "普通演唱" + @JvmField var recordEnabled = true + @JvmField var scoreEnabled = true + @JvmField var lastScore = 0 + @JvmField var lastScoreSong = "" + @JvmField var marqueeEnabled = true + @JvmField var marqueeText = "欢迎使用麦动" + @JvmField var playWhileDownloading = false + @JvmField var clearDownloadsOnBoot = false + @JvmField var autoFullscreenSeconds = 30 + @JvmField var showUsbSongs = true + @JvmField var autoDeleteSongs = true + @JvmField var reserveStorageGb = 1.0 + @JvmField var floatingButtonEnabled = true + @JvmField var songTitleSubtitleEnabled = true + @JvmField var orderedSongOriginal = true + @JvmField var orderedVolumeMode = 0 + @JvmField var orderedVocalMode = 0 + @JvmField var voiceEngineEnabled = true + @JvmField var voiceEngineVolume = 70 + @JvmField var lightMode = "自动" + @JvmField var audioDelayMs = 0 + @JvmField var screenBrightness = 80 + @JvmField var screenMode = "适应屏幕" + @JvmField var doubleScreenEnabled = false + @JvmField var tableBroadcastEnabled = true + @JvmField var tableBroadcastSeconds = 8 + @JvmField var tableBroadcastText = "欢迎光临" + @JvmField val orderQueue = mutableListOf() + @JvmField val sangHistory = mutableListOf() + @JvmField val scoreHistory = mutableListOf() + @JvmField val broadcastHistory = mutableListOf() + @JvmField val favoriteIds = LinkedHashSet() + @JvmField val hiddenSongIds = LinkedHashSet() + @JvmField val playlists = mutableListOf() + @JvmField val playlistSongs = LinkedHashMap>() + + fun load() { + runCatching { + if (!file.exists()) return@runCatching + val root = JSONObject(file.readText()) + loopOne = root.optBoolean("loopOne", false) + autoNext = root.optBoolean("autoNext", true) + originalVocal = root.optBoolean("originalVocal", true) + vocalChannelMode = root.optString("vocalChannelMode", "自动") + musicVolume = root.optInt("musicVolume", 70) + micVolume = root.optInt("micVolume", 70) + tone = root.optInt("tone", 0) + atmosphere = root.optString("atmosphere", "标准") + singMode = root.optString("singMode", "普通演唱") + recordEnabled = root.optBoolean("recordEnabled", true) + scoreEnabled = root.optBoolean("scoreEnabled", true) + lastScore = root.optInt("lastScore", 0) + lastScoreSong = root.optString("lastScoreSong", "") + marqueeEnabled = root.optBoolean("marqueeEnabled", true) + marqueeText = root.optString("marqueeText", "欢迎使用麦动") + playWhileDownloading = root.optBoolean("playWhileDownloading", false) + clearDownloadsOnBoot = root.optBoolean("clearDownloadsOnBoot", false) + autoFullscreenSeconds = root.optInt("autoFullscreenSeconds", 30) + showUsbSongs = root.optBoolean("showUsbSongs", true) + autoDeleteSongs = root.optBoolean("autoDeleteSongs", true) + reserveStorageGb = root.optDouble("reserveStorageGb", 1.0).coerceIn(0.5, 64.0) + floatingButtonEnabled = root.optBoolean("floatingButtonEnabled", true) + songTitleSubtitleEnabled = root.optBoolean("songTitleSubtitleEnabled", true) + orderedSongOriginal = root.optBoolean("orderedSongOriginal", true) + orderedVolumeMode = root.optInt("orderedVolumeMode", 0) + orderedVocalMode = root.optInt("orderedVocalMode", 0) + voiceEngineEnabled = root.optBoolean("voiceEngineEnabled", true) + voiceEngineVolume = root.optInt("voiceEngineVolume", 70) + lightMode = root.optString("lightMode", "自动") + audioDelayMs = root.optInt("audioDelayMs", 0) + screenBrightness = root.optInt("screenBrightness", 80) + screenMode = root.optString("screenMode", "适应屏幕") + doubleScreenEnabled = root.optBoolean("doubleScreenEnabled", false) + tableBroadcastEnabled = root.optBoolean("tableBroadcastEnabled", true) + tableBroadcastSeconds = root.optInt("tableBroadcastSeconds", 8) + tableBroadcastText = root.optString("tableBroadcastText", "欢迎光临") + readSongs(root.optJSONArray("orderQueue"), orderQueue) + readSongs(root.optJSONArray("sangHistory"), sangHistory) + root.optJSONArray("scoreHistory").copyStringsTo(scoreHistory) + root.optJSONArray("broadcastHistory").copyStringsTo(broadcastHistory) + root.optJSONArray("favoriteIds").copyStringsTo(favoriteIds) + root.optJSONArray("hiddenSongIds").copyStringsTo(hiddenSongIds) + root.optJSONArray("playlists").copyStringsTo(playlists) + root.optJSONObject("playlistSongs")?.let { map -> + map.names()?.let { names -> + repeat(names.length()) { + val name = names.optString(it) + val values = LinkedHashSet() + map.optJSONArray(name).copyStringsTo(values) + playlistSongs[name] = values + } + } + } + } + // 默认值迁移(v1.2.5+):原唱默认 伴唱→原唱、自动全屏 0→30 秒、点播歌曲原伴唱 伴唱→原唱。 + // 仅当旧数据仍是旧默认值时升级为新默认;已在设置里手动改过的值不受影响。 + if (!originalVocal) originalVocal = true + if (autoFullscreenSeconds == 0) autoFullscreenSeconds = 30 + if (!orderedSongOriginal) orderedSongOriginal = true + ensureDefaultPlaylists() + } + + fun save() { + writeSnapshot(snapshotJson()) + } + + fun snapshotJson(): String = runCatching { + val root = JSONObject().apply { + put("loopOne", loopOne) + put("autoNext", autoNext) + put("originalVocal", originalVocal) + put("vocalChannelMode", vocalChannelMode) + put("musicVolume", musicVolume) + put("micVolume", micVolume) + put("tone", tone) + put("atmosphere", atmosphere) + put("singMode", singMode) + put("recordEnabled", recordEnabled) + put("scoreEnabled", scoreEnabled) + put("lastScore", lastScore) + put("lastScoreSong", lastScoreSong) + put("marqueeEnabled", marqueeEnabled) + put("marqueeText", marqueeText) + put("playWhileDownloading", playWhileDownloading) + put("clearDownloadsOnBoot", clearDownloadsOnBoot) + put("autoFullscreenSeconds", autoFullscreenSeconds) + put("showUsbSongs", showUsbSongs) + put("autoDeleteSongs", autoDeleteSongs) + put("reserveStorageGb", reserveStorageGb) + put("floatingButtonEnabled", floatingButtonEnabled) + put("songTitleSubtitleEnabled", songTitleSubtitleEnabled) + put("orderedSongOriginal", orderedSongOriginal) + put("orderedVolumeMode", orderedVolumeMode) + put("orderedVocalMode", orderedVocalMode) + put("voiceEngineEnabled", voiceEngineEnabled) + put("voiceEngineVolume", voiceEngineVolume) + put("lightMode", lightMode) + put("audioDelayMs", audioDelayMs) + put("screenBrightness", screenBrightness) + put("screenMode", screenMode) + put("doubleScreenEnabled", doubleScreenEnabled) + put("tableBroadcastEnabled", tableBroadcastEnabled) + put("tableBroadcastSeconds", tableBroadcastSeconds) + put("tableBroadcastText", tableBroadcastText) + put("orderQueue", writeSongs(orderQueue)) + put("sangHistory", writeSongs(sangHistory)) + put("scoreHistory", JSONArray(scoreHistory.take(100))) + put("broadcastHistory", JSONArray(broadcastHistory.take(100))) + put("favoriteIds", JSONArray(favoriteIds.toList())) + put("hiddenSongIds", JSONArray(hiddenSongIds.toList())) + put("playlists", JSONArray(playlists)) + put("playlistSongs", JSONObject().apply { + playlistSongs.forEach { (name, ids) -> put(name, JSONArray(ids.toList())) } + }) + } + root.toString(2) + }.getOrDefault("{}") + + fun writeSnapshot(snapshot: String) { + runCatching { + file.parentFile?.mkdirs() + file.writeText(snapshot) + } + } + + fun toggleFavorite(song: Song): Boolean = toggleId(favoriteIds, stableId(song)) + + fun isFavorite(song: Song): Boolean = stableId(song) in favoriteIds + + fun togglePlaylistSong(playlist: String, song: Song): Boolean = toggleId(songsInPlaylist(playlist), stableId(song)) + + fun isInPlaylist(playlist: String, song: Song): Boolean = stableId(song) in songsInPlaylist(playlist) + + fun songsInPlaylist(playlist: String): MutableSet = playlistSongs.getOrPut(playlist) { LinkedHashSet() } + + private fun ensureDefaultPlaylists() { + listOf("我的收藏", "常唱歌曲").forEach { if (it !in playlists) playlists += it } + playlists.forEach(::songsInPlaylist) + } + + private fun toggleId(ids: MutableSet, id: String): Boolean = if (ids.remove(id)) false else ids.add(id) + + companion object { + @JvmStatic + fun stableId(song: Song): String = song.id?.takeIf(String::isNotEmpty) + ?: song.path?.takeIf(String::isNotEmpty) + ?: "${song.title}|${song.singer}" + + private fun readSongs(array: JSONArray?, out: MutableList) { + out.clear() + if (array == null) return + repeat(array.length()) { out += Song.fromJson(array.getJSONObject(it)) } + } + + private fun writeSongs(songs: List): JSONArray = JSONArray().apply { + songs.take(200).forEach { put(it.toJson()) } + } + + private fun JSONArray?.copyStringsTo(out: MutableCollection) { + if (this == null) return + repeat(length()) { out += optString(it) } + } + } +} diff --git a/app/src/main/java/com/local/ktv/LocalRemoteServer.kt b/app/src/main/java/com/local/ktv/LocalRemoteServer.kt new file mode 100644 index 0000000..7c36be3 --- /dev/null +++ b/app/src/main/java/com/local/ktv/LocalRemoteServer.kt @@ -0,0 +1,241 @@ +package com.local.ktv + +import android.content.Context +import java.io.BufferedInputStream +import java.net.Inet4Address +import java.net.InetAddress +import java.net.URI +import java.net.ServerSocket +import java.net.Socket +import java.net.URLDecoder +import java.util.Locale +import java.util.concurrent.Executors + +/** Small, dependency-free HTTP server used only by devices on the local LAN. */ +class LocalRemoteServer(private val context: Context) { + data class ApiResponse( + val status: Int = 200, + val body: String = "", + val contentType: String = "application/json; charset=utf-8", + val bytes: ByteArray? = null + ) + + interface Callback { + fun statusJson(): String + fun catalogJson(): String + fun queueJson(): String + fun rankJson(): String + fun filterJson(type: String?, value: String?): String + fun favoritesJson(): String + fun search(query: String?): String + fun order(query: String?): String + fun command(action: String?, params: Map): String + fun handle(method: String, path: String, query: Map, body: String): ApiResponse = + ApiResponse(404, "{\"ok\":false,\"code\":\"NOT_FOUND\",\"error\":\"接口不存在\"}") + } + + private var pool = Executors.newCachedThreadPool() + @Volatile private var running = false + @Volatile private var boundAddress: String? = null + @Volatile private var boundPort = -1 + @Volatile private var lastError: String? = null + private var server: ServerSocket? = null + private var callback: Callback? = null + + @Synchronized + fun start(address: String?, callback: Callback): Boolean { + if (running && address == boundAddress) return true + stop() + this.callback = callback + if (!isPrivateIpv4(address)) { + lastError = "请先连接 Wi-Fi 或有线局域网" + return false + } + if (pool.isShutdown) pool = Executors.newCachedThreadPool() + var candidate: ServerSocket? = null + for (port in 8765..8775) { + try { + candidate = ServerSocket(port, 32) + boundPort = port + break + } catch (_: Exception) { + } + } + if (candidate == null) { + lastError = "端口 8765–8775 均被占用" + return false + } + server = candidate + boundAddress = address + lastError = null + running = true + pool.execute { + try { + while (running) { + val socket = server?.accept() ?: break + pool.execute { handle(socket) } + } + } catch (e: Exception) { + if (running) lastError = e.message ?: "本地服务异常" + } finally { + running = false + } + } + return true + } + + @Synchronized + fun stop() { + running = false + runCatching { server?.close() } + server = null + boundPort = -1 + boundAddress = null + pool.shutdownNow() + } + + fun isRunning(): Boolean = running + fun port(): Int = boundPort + fun address(): String? = boundAddress + fun error(): String? = lastError + fun url(): String? = if (running && boundAddress != null && boundPort > 0) { + "http://$boundAddress:$boundPort/mobile" + } else null + + private fun handle(socket: Socket) { + runCatching { + socket.soTimeout = 5000 + socket.use { client -> + val remote = client.inetAddress + if (!isPrivateAddress(remote)) { + write(client, ApiResponse(403, error("FORBIDDEN", "仅允许局域网设备访问"))) + return + } + val input = BufferedInputStream(client.getInputStream()) + val requestLine = readLine(input) ?: return + val parts = requestLine.split(' ') + if (parts.size < 2) { + write(client, ApiResponse(400, error("BAD_REQUEST", "请求格式错误"))) + return + } + val method = parts[0].uppercase(Locale.US) + val target = parts[1] + val headers = linkedMapOf() + while (true) { + val line = readLine(input) ?: break + if (line.isEmpty()) break + val colon = line.indexOf(':') + if (colon > 0) headers[line.substring(0, colon).lowercase(Locale.US)] = line.substring(colon + 1).trim() + } + if (!validHost(headers["host"]) || !validOrigin(headers["origin"])) { + write(client, ApiResponse(403, error("BAD_ORIGIN", "请求来源无效"))) + return + } + val length = headers["content-length"]?.toIntOrNull()?.coerceIn(0, 65536) ?: 0 + val bodyBytes = ByteArray(length) + var offset = 0 + while (offset < length) { + val count = input.read(bodyBytes, offset, length - offset) + if (count < 0) break + offset += count + } + val path = target.substringBefore('?') + val query = parseQuery(target.substringAfter('?', "")) + val body = String(bodyBytes, 0, offset, Charsets.UTF_8) + val response = when { + path == "/" || path == "/mobile" || path == "/mobile/" -> asset("mobile/index.html", "text/html; charset=utf-8") + path == "/mobile/app.css" -> asset("mobile/app.css", "text/css; charset=utf-8") + path == "/mobile/logo.css" -> asset("mobile/logo.css", "text/css; charset=utf-8") + path == "/mobile/app.js" -> asset("mobile/app.js", "application/javascript; charset=utf-8") + path == "/res/drawable-nodpi/ktv_logo.png" -> logoAsset() + path.startsWith("/api/v1/") -> callback?.handle(method, path, query, body) + ?: ApiResponse(503, error("NOT_READY", "服务尚未准备完成")) + else -> ApiResponse(404, error("NOT_FOUND", "接口不存在")) + } + write(client, response) + } + } + } + + private fun asset(name: String, type: String): ApiResponse = try { + val text = context.assets.open(name).bufferedReader(Charsets.UTF_8).use { it.readText() } + ApiResponse(200, text, type) + } catch (_: Exception) { + ApiResponse(404, error("ASSET_NOT_FOUND", "页面资源缺失")) + } + + private fun logoAsset(): ApiResponse = try { + val bytes = context.resources.openRawResource(R.drawable.ktv_logo).use { it.readBytes() } + ApiResponse(200, contentType = "image/png", bytes = bytes) + } catch (_: Exception) { + ApiResponse(404, error("ASSET_NOT_FOUND", "Logo 资源缺失")) + } + + private fun validHost(host: String?): Boolean { + if (host.isNullOrBlank()) return false + val expected = boundAddress ?: return false + val name = host.substringBefore(':') + return name == expected || isPrivateIpv4(name) + } + + private fun validOrigin(origin: String?): Boolean { + if (origin.isNullOrBlank() || origin == "null") return true + return runCatching { + val uri = URI(origin) + uri.scheme == "http" && uri.port == boundPort && isPrivateIpv4(uri.host) + }.getOrDefault(false) + } + + private fun readLine(input: BufferedInputStream): String? { + val bytes = ArrayList() + while (bytes.size < 8192) { + val b = input.read() + if (b < 0) return if (bytes.isEmpty()) null else bytes.toByteArray().toString(Charsets.UTF_8) + if (b == '\n'.code) break + if (b != '\r'.code) bytes.add(b.toByte()) + } + return bytes.toByteArray().toString(Charsets.UTF_8) + } + + private fun parseQuery(query: String): Map = buildMap { + if (query.isBlank()) return@buildMap + query.split('&').forEach { part -> + val key = URLDecoder.decode(part.substringBefore('='), "UTF-8") + val value = URLDecoder.decode(part.substringAfter('=', ""), "UTF-8") + put(key, value) + } + } + + private fun write(socket: Socket, response: ApiResponse) { + val bytes = response.bytes ?: response.body.toByteArray(Charsets.UTF_8) + val reason = when (response.status) { + 200 -> "OK"; 201 -> "Created"; 204 -> "No Content"; 400 -> "Bad Request" + 403 -> "Forbidden"; 404 -> "Not Found"; 409 -> "Conflict"; else -> "Error" + } + val output = socket.getOutputStream() + output.write(("HTTP/1.1 ${response.status} $reason\r\n" + + "Content-Type: ${response.contentType}\r\n" + + "Cache-Control: no-store\r\n" + + "X-Content-Type-Options: nosniff\r\n" + + "Content-Security-Policy: default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'\r\n" + + "Content-Length: ${bytes.size}\r\nConnection: close\r\n\r\n").toByteArray()) + output.write(bytes) + output.flush() + } + + companion object { + fun isPrivateIpv4(value: String?): Boolean = runCatching { + val address = InetAddress.getByName(value) + address is Inet4Address && isPrivateAddress(address) + }.getOrDefault(false) + + private fun isPrivateAddress(address: InetAddress): Boolean { + if (address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress) return true + val b = address.address + return b.size == 4 && (b[0].toInt() and 0xff) == 100 && (b[1].toInt() and 0xc0) == 64 + } + + private fun error(code: String, message: String): String = + "{\"ok\":false,\"code\":\"$code\",\"error\":\"$message\"}" + } +} diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt new file mode 100644 index 0000000..01fc374 --- /dev/null +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -0,0 +1,10027 @@ +package com.local.ktv + +import android.Manifest +import android.app.AlertDialog +import android.content.DialogInterface +import android.content.DialogInterface.OnShowListener +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.ColorStateList +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.MediaPlayer.OnCompletionListener +import android.media.MediaPlayer.OnPreparedListener +import android.media.MediaPlayer.TrackInfo +import android.media.MediaRecorder +import android.net.ConnectivityManager +import android.net.Uri +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.text.Editable +import android.text.InputType +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.view.Gravity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.View.OnFocusChangeListener +import android.view.View.OnTouchListener +import android.view.ViewGroup +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.AdapterView +import android.widget.AdapterView.OnItemClickListener +import android.widget.AdapterView.OnItemLongClickListener +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.GridLayout +import android.widget.HorizontalScrollView +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.ProgressBar +import android.widget.ScrollView +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.TextView +import android.widget.TextView.OnEditorActionListener +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import com.local.ktv.KtvStore.Companion.stableId +import com.local.ktv.Song.Companion.local +import com.local.ktv.SongApiClient.getSongDownloadUrl +import com.local.ktv.SongApiClient.init +import com.local.ktv.SongOkDownloadManager.DownloadCallback +import com.local.ktv.SongOkDownloadManager.download +import com.local.ktv.SongOkDownloadManager.getLocalFile +import com.local.ktv.SongOkDownloadManager.isDownloaded +import com.local.ktv.player.PlayerControllerView +import com.local.ktv.player.PlayerControllerView.OnControlListener +import com.local.ktv.player.KtvPlaybackEngine +import com.local.ktv.player.KtvVideoView +import com.local.ktv.player.VocalSwitchHelper +import com.local.ktv.player.VocalSwitchHelper.switchVocal +import tv.danmaku.ijk.media.player.IjkMediaPlayer +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.BufferedReader +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.Inet4Address +import java.net.NetworkInterface +import java.net.URL +import java.net.URLDecoder +import java.text.SimpleDateFormat +import java.util.Collections +import java.util.Date +import java.util.ArrayDeque +import java.util.Locale +import java.util.LinkedHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.function.Function +import java.util.function.ToIntFunction +import kotlin.Any +import kotlin.Array +import kotlin.Boolean +import kotlin.ByteArray +import kotlin.Comparator +import kotlin.Exception +import kotlin.Float +import kotlin.FloatArray +import kotlin.Int +import kotlin.IntArray +import kotlin.String +import kotlin.Throws +import kotlin.also +import kotlin.arrayOf +import kotlin.arrayOfNulls +import kotlin.check +import kotlin.floatArrayOf +import kotlin.intArrayOf +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.plus +import kotlin.toString + +class MainActivity : AppCompatActivity() { + private val main = Handler(Looper.getMainLooper()) + private val keyboardSearchRunnable = Runnable { performSearch() } + private val io: ExecutorService = Executors.newFixedThreadPool(4) + private val stateIo: ExecutorService = Executors.newSingleThreadExecutor() + private val library = SongLibrary() + private lateinit var stateDatabase: KtvStateDatabase + private lateinit var playbackEngine: KtvPlaybackEngine + + /** + * 获取 KTV 状态存储对象(供 Fragment 访问已点/已唱/收藏/歌单数据)。 + * + * @return 全局 KtvStore 实例 + */ + val store: KtvStore = KtvStore() + private val remoteServer by lazy { LocalRemoteServer(this) } + @Volatile private var remoteStateVersion = 1L + private val remoteSongCache = Collections.synchronizedMap(LinkedHashMap()) + private var mobileQrOverlay: View? = null + private var fullScreenQrOverlay: View? = null + private var updateCheckStarted = false + private var compactUi = false + private val remoteNetworkMonitor = object : Runnable { + override fun run() { + val ip = localIp().takeIf { LocalRemoteServer.isPrivateIpv4(it) } + if (ip != remoteServer.address()) { + remoteServer.stop() + startRemoteServer() + updateMobileQrOverlay() + updateFullScreenQrOverlay() + } + main.postDelayed(this, 5000) + } + } + /** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */ + private val ktvHeartbeatRunnable = object : Runnable { + override fun run() { + sendDeviceHeartbeat(boot = false) + main.postDelayed(this, HEARTBEAT_INTERVAL_MS) + } + } + private val visibleSongs: MutableList = ArrayList() + private val visibleSingers: MutableList?> = ArrayList?>() + private val visiblePlaylists: MutableList> = ArrayList() + private val orderQueue: MutableList? = store.orderQueue + private val sangHistory = store.sangHistory + + /** + * 获取当前下载任务列表(供 DownloadListFragment 展示下载队列)。 + * + * @return 全局下载任务列表 + */ + val downloads: MutableList = ArrayList() + + private var content: LinearLayout? = null + private var nav: LinearLayout? = null + private var header: TextView? = null + private var status: TextView? = null + private var progress: ProgressBar? = null + private var databaseLoadingOverlay: FrameLayout? = null + private var databaseLoadingText: TextView? = null + private var databaseLoadingProgress: ProgressBar? = null + private var databaseRetryButton: TextView? = null + @Volatile private var databaseBootstrapRunning = false + private var player: KtvVideoView? = null + + /** 播放器控制层(叠加在 player 之上) */ + private var playerController: PlayerControllerView? = null + private var currentMediaPlayer: MediaPlayer? = null + private var vocalPlayer: MediaPlayer? = null + private var embeddedVocalEngine: KtvPlaybackEngine? = null + private var vocalPlayerPrepared = false + private var pendingVocalPosition = 0 + private var embeddedDualTrackMode = false + private var embeddedMainTrackOriginal = true + + /** + * 获取当前正在播放的歌曲(供已点列表标记播放状态)。 + * + * @return 当前播放的歌曲,未播放时返回 null + */ + var currentSong: Song? = null + private set + private var currentVocalPath = "" + private var lyricCurrent: TextView? = null + private var lyricNext: TextView? = null + private val lyricLines: MutableList = ArrayList() + private val lyricTicker: Runnable = object : Runnable { + override fun run() { + updateFullScreenProgress() + updateLyricView() + main.postDelayed(this, 500) + } + } + private var search: EditText? = null + private var songList: ListView? = null + private var modernSongAdapter: SongListAdapter? = null + private var queueList: ListView? = null + private var songAdapter: ArrayAdapter? = null + private var queueAdapter: ArrayAdapter? = null + private var audioManager: AudioManager? = null + private var recorder: MediaRecorder? = null + private var currentRecording: File? = null + private var currentPage = "首页" + + private data class FocusBookmark( + val resourceName: String?, + val marker: String?, + val text: String?, + val groupText: String?, + ) + + private data class FocusReturnPoint( + val restorePage: () -> Unit, + val focus: FocusBookmark?, + val directFocus: View?, + ) + + private val focusReturnStack = ArrayDeque() + private var restoringFocusRoute = false + private var focusRestoreSerial = 0 + + /** 导航按钮列表,用于维护选中态视觉 */ + private val navButtons: MutableList = ArrayList() + + /** 当前选中的导航索引(-1 表示未选中,如初始主页) */ + private var currentNavIndex = -1 + + // ===== 分页浏览状态(用于数据库驱动的曲库浏览) ===== + /** 当前浏览模式的页码(从0开始) */ + private var browsePage = 0 + + /** 当前浏览模式的总页数 */ + private var browseTotalPages = 0 + + /** 原版一屏展示量:歌曲 2x5、歌手 4x2、排行榜 1x6。 */ + private fun browsePageSize(): Int = when (browseMode) { + "rank" -> 6 + "singer_list" -> 8 + else -> MuseDatabase.PAGE_SIZE + } + + /** 当前浏览类型:"hot"/"language"/"wordcount"/"singer"/"search" */ + private var browseMode = "hot" + + /** 当前浏览的筛选参数(如语种名、歌手ID、搜索关键词等) */ + private var browseParam: String? = "" + + /** 当前浏览的总记录数 */ + private var browseTotalCount = 0 + private var browseRequestVersion = 0 + private data class BrowseCacheKey( + val mode: String, + val param: String, + val language: String, + val searchQuery: String, + val page: Int, + val pageSize: Int, + ) + private data class BrowsePageResult(val songs: List, val total: Int) + private val browsePageCache = object : LinkedHashMap(48, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > 48 + } + private val browseCountCache = object : LinkedHashMap(24, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 24 + } + private val browseRequestsInFlight = HashSet() + private val browseDisplayRequests = HashMap() + private var searchScope = "song" + private var activeSettingsEntries: List = emptyList() + private var activeSearchQuery = "" + private var searchLanguage = "全部" + private var searchContextId = "" + private var suppressSearchWatcher = false + + /** 用户在大曲库打开前进入了曲库页面,打开完成后需要重放该次查询。 */ + private var catalogRefreshPending = false + + /** 显示分页信息的 TextView */ + private var pageInfo: TextView? = null + private var loopOne = false + private var autoNext = true + private var songRetryCount = 0 // 播放错误重试计数 + private var playbackPreparing = false + private var playWhenPrepared = true + private var playbackGeneration = 0L + private val pendingQueueSongIds = LinkedHashSet() + private var suppressCompletionUntil = 0L + private val autoFullscreenRunnable = Runnable { + val eligible = autoFullscreenSeconds > 0 && currentSong != null && playWhenPrepared && !isFullScreen + Log.d(TAG, "autoFullscreen fired eligible=$eligible seconds=$autoFullscreenSeconds " + + "song=${currentSong?.let(::stableId)} playing=$playWhenPrepared full=$isFullScreen") + if (eligible) { + showFullScreenPlayer() + } + } + private var homeBannerIndex = 0 + private val homeBanners = intArrayOf( + R.drawable.home_banner_0, + R.drawable.home_banner_1, + R.drawable.home_banner_2, + R.drawable.home_banner_3, + R.drawable.home_banner_4, + ) + private val homeBannerRunnable = object : Runnable { + override fun run() { + homeBannerIndex = (homeBannerIndex + 1) % homeBanners.size + homePosterView?.setBackgroundResource(homeBanners[homeBannerIndex]) + textPosterPage?.text = "${homeBannerIndex + 1}/${homeBanners.size}" + main.postDelayed(this, 5000L) + } + } + private var originalVocal = true + private var vocalChannelMode = "自动" + private var musicVolume = 70 + private var micVolume = 70 + private var tone = 0 + private var atmosphere = "标准" + private var singMode = "普通演唱" + private var recordEnabled = true + private var scoreEnabled = true + private var marqueeEnabled = true + private var marqueeText: String? = "欢迎使用麦动" + private var playWhileDownloading = false + private var clearDownloadsOnBoot = false + private var autoFullscreenSeconds = 30 + private var showUsbSongs = true + private var autoDeleteSongs = true + private var reserveStorageGb = 1.0 + private var floatingButtonEnabled = true + private var songTitleSubtitleEnabled = true + private var orderedSongOriginal = true + private var orderedVolumeMode = 0 + private var orderedVocalMode = 0 + private var voiceEngineEnabled = true + private var voiceEngineVolume = 70 + private var lightMode: String? = "自动" + private var audioDelayMs = 0 + private var screenBrightness = 80 + private var screenMode = "适应屏幕" + private var doubleScreenEnabled = false + private var tableBroadcastEnabled = true + private var tableBroadcastSeconds = 8 + private var tableBroadcastText: String? = "欢迎光临" + + private class LyricLine(val timeMs: Int, val text: String?) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + getWindow().getDecorView().setSystemUiVisibility( + (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE) + ) + // 使用新的横屏TV布局 + setContentView(R.layout.activity_main) + applyResponsiveLayout() + if (!hasStoragePermission()) { + requestStorage() + return + } + AppPaths.ensureDirectories() + store.load() + loadStateFromStore() + stateDatabase = KtvStateDatabase(applicationContext) + if (clearDownloadsOnBoot) clearDownloadedFilesOnBoot() + val restoredState = runCatching { + stateDatabase.restoreOrSeed(orderQueue!!, sangHistory) + }.getOrElse { KtvStateDatabase.RestoredState(null, "idle") } + currentSong = restoredState.currentSong + playWhenPrepared = restoredState.playbackState != "paused" + runCatching { + stateDatabase.restoreDownloads().forEach { snapshot -> + if (snapshot.status == "completed") return@forEach + snapshot.localPath?.let { snapshot.song.path = it } + downloads += DownloadTask(snapshot.song).apply { + progress = snapshot.progress + state = when (snapshot.status) { + "queued", "downloading" -> "下载中断,可重试" + "completed" -> "完成" + "failed" -> "失败:${snapshot.error.orEmpty()}" + else -> snapshot.status + } + } + if (snapshot.status == "queued" || snapshot.status == "downloading") { + stateDatabase.updateDownload( + snapshot.song, + "failed", + snapshot.progress, + snapshot.localPath, + "应用重启,等待重试", + ) + } + } + } + applyScreenBrightness() + audioManager = getSystemService(AUDIO_SERVICE) as AudioManager? + // 初始化 API 客户端 (歌曲下载链接) + init("abe235a87118f6de", "080027deed4f") + playbackEngine = KtvPlaybackEngine(applicationContext) + // 远程日志系统: 加载打包证书并后台连接 syslog 服务器, 启动即上报设备/网络信息。 + MiliLog.init(applicationContext) + reportDeviceBootInfo() + // 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403, + // 换公网 IP 即恢复。到期时弹一次提示引导用户重启光猫/路由器(重拨换 IP)。 + Eac19WindowGuard.onBlocked = { + main.post { + if (isFinishing || isDestroyed) return@post + AlertDialog.Builder(this) + .setTitle("歌曲源临时受限") + .setMessage( + "歌曲源在当前网络(公网 IP)下暂时无法获取新歌播放地址(风控/试用窗口)。\n\n" + + "解决办法:重启光猫/路由器重新拨号更换公网 IP,恢复后即可继续点歌。\n\n" + + "已缓存的歌曲不受影响,可正常播放。", + ) + .setPositiveButton("知道了", null) + .create() + .showForTv(DialogInterface.BUTTON_POSITIVE) + } + } + // 初始化新布局的 UI 组件 + initTvLayout() + val startupSong = restoredState.currentSong ?: orderQueue!!.firstOrNull() + startupSong?.let { restoredSong -> + play(restoredSong) + updateBottomBar(restoredSong, true) + } + // Keep initialization on the loading state until the external catalog is ready. + SongApiClient.init(this) // 主线程初始化 JS Bridge + initializeDatabase() + startRemoteServer() + updateMobileQrOverlay() + main.postDelayed(remoteNetworkMonitor, 5000) + // 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。 + sendDeviceHeartbeat(boot = true) + main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS) + main.post(lyricTicker) + main.postDelayed({ checkAppUpdate(false) }, 2500L) + } + + private fun initializeDatabase() { + if (databaseBootstrapRunning) return + databaseBootstrapRunning = true + showDatabaseLoading(true, "正在初始化曲库...", null) + io.execute { + val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion() + val localVersion = DatabaseBootstrapper.getLocalDbVersion() + val updateRequired = remoteVersion != null && remoteVersion != localVersion + var bootstrapError: Throwable? = null + var usedOldDatabase = false + + var ok = if (updateRequired) false else library.muse.open() + if (!ok) { + library.muse.close() + val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress) + bootstrapError = result.exceptionOrNull() + ok = result.isSuccess && library.muse.open() + if (!ok) { + // A failed online update must not make an existing local catalog unusable. + usedOldDatabase = library.muse.open() + ok = usedOldDatabase + } + } + + val count = if (ok) library.muse.songCount() else 0 + if (ok) warmRemoteBrowseCache(count) + main.post { + databaseBootstrapRunning = false + if (ok) { + refreshLibrary { + showDatabaseLoading(false, "", null) + showHomePage() + if (usedOldDatabase && bootstrapError != null) toast("曲库更新失败,已使用本地曲库") + if (catalogRefreshPending) refreshCurrentCatalogPage() + } + } else { + val reason = bootstrapError?.message.orEmpty().ifBlank { "未找到可用曲库" } + showDatabaseLoadingFailure("曲库初始化失败:$reason") + } + } + } + } + + private fun showDatabaseDownloadProgress(value: Int) { + val phase = when { + value < 80 -> "正在下载并解压曲库" + value < 90 -> "正在完成解压" + value < 100 -> "正在校验数据库" + else -> "完成" + } + main.post { showDatabaseLoading(true, "$phase ${value}%", value) } + } + + private fun warmRemoteBrowseCache(count: Int) { + val pageSize = MuseDatabase.PAGE_SIZE + val songs = library.muse.hotSongs(0, pageSize * 2) + synchronized(browseCountCache) { browseCountCache["hot\u0001\u0001全部\u0001"] = count } + songs.chunked(pageSize).forEachIndexed { page, pageSongs -> + val key = BrowseCacheKey("hot", "", "全部", "", page, pageSize) + synchronized(browsePageCache) { browsePageCache[key] = BrowsePageResult(pageSongs, count) } + } + } + + override fun onResume() { + super.onResume() + } + + @Deprecated("Deprecated in Android") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + } + + override fun onDestroy() { + super.onDestroy() + io.shutdownNow() + main.removeCallbacks(lyricTicker) + main.removeCallbacks(homeBannerRunnable) + main.removeCallbacks(autoFullscreenRunnable) + main.removeCallbacks(remoteNetworkMonitor) + stopRecording(false) + releaseVocalPlayer() + if (::playbackEngine.isInitialized) playbackEngine.release() + remoteServer.stop() + library.muse.close() + stateIo.shutdown() + runCatching { stateIo.awaitTermination(2, TimeUnit.SECONDS) } + if (::stateDatabase.isInitialized) stateDatabase.close() + } + + private fun checkAppUpdate(manual: Boolean) { + if (updateCheckStarted) return + updateCheckStarted = true + if (manual) toast("正在检查更新...") + AppUpdateManager.check(BuildConfig.VERSION_NAME) { result -> + main.post { + updateCheckStarted = false + result.onSuccess { release -> + if (release == null) { + if (manual) toast("当前已是最新版本 ${BuildConfig.VERSION_NAME}") + } else { + showAppUpdateDialog(release) + } + }.onFailure { error -> + Log.w(TAG, "Update check failed", error) + if (manual) toast(error.message ?: "检查更新失败") + } + } + } + } + + private fun showAppUpdateDialog(release: AppUpdateManager.Release) { + val notes = release.notes.trim().ifBlank { "发现新版本,建议立即更新。" } + AlertDialog.Builder(this) + .setTitle("发现新版本 ${release.version}") + .setMessage("${release.title}\n\n$notes") + .setNegativeButton("稍后", null) + .setPositiveButton("下载并安装") { _, _ -> downloadAppUpdate(release) } + .create().showForTv(DialogInterface.BUTTON_POSITIVE) + } + + private fun downloadAppUpdate(release: AppUpdateManager.Release) { + val box = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(24), dp(18), dp(24), dp(12)) + } + val value = label("正在下载 ${release.apkName}…", 16, Color.WHITE).apply { gravity = Gravity.CENTER } + val bar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { max = 100 } + box.addView(value, LinearLayout.LayoutParams(-1, dp(44))) + box.addView(bar, LinearLayout.LayoutParams(-1, dp(24))) + val dialog = AlertDialog.Builder(this) + .setTitle("应用更新") + .setView(box) + .setNegativeButton("后台下载", null) + .create() + dialog.showForTv() + AppUpdateManager.download(this, release, { progress -> + main.post { + bar.progress = progress + value.text = "正在下载 ${release.apkName}… $progress%" + } + }) { result -> + main.post { + dialog.dismiss() + result.onSuccess { apk -> + AlertDialog.Builder(this) + .setTitle("下载完成") + .setMessage("版本 ${release.version} 已下载,是否现在安装?") + .setNegativeButton("稍后", null) + .setPositiveButton("安装") { _, _ -> AppUpdateManager.install(this, apk) } + .create().showForTv(DialogInterface.BUTTON_POSITIVE) + }.onFailure { error -> + AlertDialog.Builder(this).setTitle("更新失败") + .setMessage(error.message ?: "下载安装包失败") + .setPositiveButton("确定", null).show() + } + } + } + } + + /** + * XML 保留电视端的大触控/遥控焦点尺寸;窄屏横屏设备在运行时切换紧凑尺寸。 + * 平板及电视继续使用原始布局,手机不会再因固定 370dp 左栏和顶部按钮而溢出。 + */ + private fun applyResponsiveLayout() { + val config = resources.configuration + compactUi = config.screenWidthDp < 1000 || config.screenHeightDp < 600 + if (!compactUi) return + val widthDp = config.screenWidthDp.coerceAtLeast(480) + val barHeight = dp(52) + findViewById(R.id.top_bar)?.apply { + layoutParams = layoutParams.apply { height = barHeight } + setPadding(dp(10), 0, dp(8), 0) + } + findViewById(R.id.bottom_bar)?.apply { + layoutParams = layoutParams.apply { height = barHeight } + setPadding(dp(10), 0, dp(10), 0) + } + findViewById(R.id.text_logo)?.layoutParams = + findViewById(R.id.text_logo).layoutParams.apply { width = dp(34); height = dp(34) } + findViewById(R.id.top_brand_name)?.apply { textSize = 16f } + if (widthDp < 760) { + findViewById(R.id.top_brand_name)?.visibility = View.GONE + findViewById(R.id.btn_top_search)?.visibility = View.GONE + } + listOf( + R.id.btn_top_search, R.id.text_top_order_label, R.id.btn_top_vocal, + R.id.btn_top_next, R.id.btn_top_pause, R.id.btn_top_replay, R.id.btn_top_tone, + ).forEach { id -> + findViewById(id)?.apply { + textSize = 11f + setPadding(dp(5), paddingTop, dp(6), paddingBottom) + } + } + findViewById(R.id.content_frame)?.let { frame -> + (frame.layoutParams as? FrameLayout.LayoutParams)?.apply { + topMargin = barHeight + bottomMargin = barHeight + frame.layoutParams = this + } + } + findViewById(R.id.main_layout)?.setPadding(dp(12), dp(10), dp(12), dp(8)) + findViewById(R.id.home_layout)?.setPadding(dp(12), dp(10), dp(12), dp(8)) + findViewById(R.id.left_column)?.let { left -> + val desiredWidthDp = (widthDp * 0.34f).toInt().coerceIn(230, 340) + left.layoutParams = left.layoutParams.apply { width = dp(desiredWidthDp) } + findViewById(R.id.player_container)?.layoutParams = + findViewById(R.id.player_container).layoutParams.apply { + height = dp((desiredWidthDp * 9f / 16f).toInt()) + } + } + listOf( + R.id.home_card_regular, R.id.home_card_favorite, R.id.home_card_category, + R.id.home_card_rank, R.id.home_card_song, R.id.home_card_singer, R.id.home_card_local, + ).forEach { id -> + findViewById(id)?.apply { textSize = 15f; setPadding(dp(8), dp(8), dp(8), dp(8)) } + } + } + + private fun chromeBarHeight(): Int = dp(if (compactUi) 52 else 64) + + private fun buildShell() { + val root = FrameLayout(this) + root.setBackgroundColor(BG) + + val shell = LinearLayout(this) + shell.setOrientation(LinearLayout.HORIZONTAL) + shell.setPadding(dp(16), dp(14), dp(16), dp(12)) + root.addView(shell, FrameLayout.LayoutParams(-1, -1)) + + val navScroll = ScrollView(this) + navScroll.setFillViewport(true) + navScroll.setBackgroundColor(Color.rgb(12, 18, 25)) + shell.addView(navScroll, LinearLayout.LayoutParams(dp(168), -1)) + + nav = LinearLayout(this) + nav!!.setOrientation(LinearLayout.VERTICAL) + nav!!.setPadding(dp(10), dp(10), dp(10), dp(10)) + navScroll.addView(nav, FrameLayout.LayoutParams(-1, -2)) + + val brand = label("麦动", 26, GOLD) + brand.setGravity(Gravity.CENTER) + nav!!.addView(brand, LinearLayout.LayoutParams(-1, dp(64))) + // 导航项(图标 + 文字),选中态由 selectNav() 维护 + navButtons.clear() + addNav("🏠 主页", View.OnClickListener { v: View? -> showHome() }) + addNav("🎵 点歌", View.OnClickListener { v: View? -> showSongs("全部") }) + addNav("🎤 歌星", View.OnClickListener { v: View? -> showSingers() }) + addNav("🏆 排行榜", View.OnClickListener { v: View? -> showRank() }) + addNav("🌐 语种", View.OnClickListener { v: View? -> showLanguages() }) + addNav("🔢 字数", View.OnClickListener { v: View? -> showWordCounts() }) + addNav("⭐ 收藏", View.OnClickListener { v: View? -> showFavorites() }) + addNav("📋 歌单", View.OnClickListener { v: View? -> showPlaylists() }) + addNav("📝 已点", View.OnClickListener { v: View? -> showQueue() }) + addNav("🎵 已唱", View.OnClickListener { v: View? -> showSang() }) + addNav("⬇️ 下载", View.OnClickListener { v: View? -> showDownloads() }) + addNav("💾 U盘", View.OnClickListener { v: View? -> showUdisk() }) + addNav("🎬 影片", View.OnClickListener { v: View? -> showLocalMovies() }) + addNav("🎉 派对", View.OnClickListener { v: View? -> showDisco() }) + addNav("📡 网络", View.OnClickListener { v: View? -> showNetworkDialog() }) + addNav("🖥️ 屏幕", View.OnClickListener { v: View? -> showScreenDialog() }) + addNav("📦 应用", View.OnClickListener { v: View? -> showAppCenter() }) + addNav("🔧 维护", View.OnClickListener { v: View? -> showLibraryMaintenance() }) + addNav("📻 广播", View.OnClickListener { v: View? -> showTableBroadcast() }) + addNav("⚙️ 设置", View.OnClickListener { v: View? -> showSettings() }) + + val body = LinearLayout(this) + body.setOrientation(LinearLayout.VERTICAL) + body.setPadding(dp(14), 0, 0, 0) + shell.addView(body, LinearLayout.LayoutParams(0, -1, 1f)) + + val top = LinearLayout(this) + top.setGravity(Gravity.CENTER_VERTICAL) + top.setPadding(0, 0, 0, dp(6)) + header = label("首页", 24, TEXT_WHITE) + top.addView(header, LinearLayout.LayoutParams(0, dp(54), 1f)) + status = label("准备就绪", 16, TEXT_DIM) + status!!.setGravity(Gravity.RIGHT or Gravity.CENTER_VERTICAL) + top.addView(status, LinearLayout.LayoutParams(dp(430), dp(54))) + body.addView(top) + // 顶栏底部分割线(原版风格) + val topDivider = View(this) + topDivider.setBackgroundColor(DIVIDER) + body.addView(topDivider, LinearLayout.LayoutParams(-1, dp(1))) + + content = LinearLayout(this) + content!!.setOrientation(LinearLayout.HORIZONTAL) + body.addView(content, LinearLayout.LayoutParams(-1, 0, 1f)) + + progress = ProgressBar(this) + progress!!.setVisibility(View.GONE) + val pp = FrameLayout.LayoutParams(dp(42), dp(42), Gravity.BOTTOM or Gravity.RIGHT) + pp.rightMargin = dp(24) + pp.bottomMargin = dp(14) + root.addView(progress, pp) + + // 将构建好的 shell 内容挂载到 main_activity 布局的 R.id.container 容器中 + // 注意:现在使用 MainFragment 加载原版布局,buildShell 的程序化 UI 不再直接添加到 container + // 保留 buildShell 中的播放器、下载等核心组件初始化逻辑,但 UI 由 Fragment 管理 + val container = findViewById(R.id.container) + if (container != null) { + // 不再添加程序化导航 UI,避免与 MainFragment 冲突 + // shell 中的播放器和下载组件仍通过 root 引用保持工作 + } else { + setContentView(root) + } + } + + /** + * 更新导航栏选中态视觉 + * @param index 要选中的导航项索引(0-based),-1 表示取消选中 + */ + private fun selectNav(index: Int) { + currentNavIndex = index + for (i in navButtons.indices) { + val btn = navButtons.get(i) + if (i == index) { + // 选中态:半透明红背景 + 红色文字 + 左侧红色色条效果(通过 padding 模拟) + btn.setBackgroundColor(NAV_SELECTED) + btn.setTextColor(ACCENT_RED) + btn.setPadding(dp(16), dp(6), dp(8), dp(6)) + } else { + // 未选中:透明背景 + 白色文字 + btn.setBackgroundColor(Color.TRANSPARENT) + btn.setTextColor(TEXT_WHITE) + btn.setPadding(dp(8), dp(6), dp(8), dp(6)) + } + } + } + + private fun showHome() { + if (content == null) return // buildShell 未启用 + + setPage("首页") + selectNav(0) + if (content == null) return + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(0, -1, 1.25f)) + left.addView(sectionTitle("快速入口")) + val grid = GridLayout(this) + grid.setColumnCount(3) + left.addView(grid, LinearLayout.LayoutParams(-1, 0, 1f)) + addTile( + grid, + "新歌推荐", + "网络/本地新增歌曲", + View.OnClickListener { v: View? -> showSongs("全部") }) + addTile(grid, "热门排行", "按点播热度排序", View.OnClickListener { v: View? -> showRank() }) + addTile(grid, "歌星点歌", "按歌手查找", View.OnClickListener { v: View? -> showSingers() }) + addTile( + grid, + "语种点歌", + "国语/粤语/外语", + View.OnClickListener { v: View? -> showLanguages() }) + addTile( + grid, + "字数点歌", + "按歌名字数查找", + View.OnClickListener { v: View? -> showWordCounts() }) + addTile( + grid, + "拼音搜索", + "支持简码/歌名/歌手", + View.OnClickListener { v: View? -> showSearch() }) + addTile( + grid, + "我的收藏", + "常唱歌曲快速点播", + View.OnClickListener { v: View? -> showFavorites() }) + addTile( + grid, + "我的歌单", + "本地自建歌单", + View.OnClickListener { v: View? -> showPlaylists() }) + addTile(grid, "U盘导入", "扫描导入目录", View.OnClickListener { v: View? -> showUdisk() }) + addTile( + grid, + "下载管理", + "网络曲库下载", + View.OnClickListener { v: View? -> showDownloads() }) + + addTile( + grid, + "音量音调", + "原伴唱 / 音量 / 升降调", + View.OnClickListener { v: View? -> showAudioControlDialog() }) + addTile( + grid, + "气氛模式", + atmosphere, + View.OnClickListener { v: View? -> showAtmosphereDialog() }) + addTile( + grid, + "演唱评分", + if (scoreEnabled) scoreSummary() else "评分已关闭", + View.OnClickListener { v: View? -> showScoreDialog() }) + addTile( + grid, + "本地录音", + if (recorder == null) "录制演唱音频" else "正在录音", + View.OnClickListener { v: View? -> showRecordings() }) + addTile( + grid, + "手机点歌", + remoteUrl(), + View.OnClickListener { v: View? -> showMobileRemoteDialog() }) + addTile( + grid, + "跑马灯", + if (marqueeEnabled) marqueeText else "跑马灯已关闭", + View.OnClickListener { v: View? -> showMarqueeSettings() }) + addTile(grid, "灯光迪斯科", lightMode, View.OnClickListener { v: View? -> showDisco() }) + addTile( + grid, + "本地影片", + "用户 MV / 影片播放", + View.OnClickListener { v: View? -> showLocalMovies() }) + addTile( + grid, + "网络设置", + remoteUrl(), + View.OnClickListener { v: View? -> showNetworkDialog() }) + addTile( + grid, + "屏幕设置", + screenMode, + View.OnClickListener { v: View? -> showScreenDialog() }) + addTile( + grid, + "应用工具", + "本地工具 / 维护", + View.OnClickListener { v: View? -> showAppCenter() }) + addTile( + grid, + "曲库维护", + "清理 / 重建 / 导出", + View.OnClickListener { v: View? -> showLibraryMaintenance() }) + addTile( + grid, + "包房广播", + if (tableBroadcastEnabled) tableBroadcastText else "广播已关闭", + View.OnClickListener { v: View? -> showTableBroadcast() }) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 0.8f)) + right.addView(sectionTitle("播放预览")) + // 用 FrameLayout 包裹共享播放 Surface,以便叠加控制层 + val playerContainer = FrameLayout(this) + player = KtvVideoView(this).also { + it.bind(playbackEngine) + playbackEngine.attach(it) + } + player!!.setBackgroundColor(Color.BLACK) + playerContainer.addView(player, FrameLayout.LayoutParams(-1, -1)) + // 初始化播放器控制层并叠加到视频上方 + playerController = PlayerControllerView(this) + initPlayerControllerCallbacks() + playerContainer.addView(playerController, FrameLayout.LayoutParams(-1, -1)) + right.addView(playerContainer, LinearLayout.LayoutParams(-1, 0, 1f)) + // 同步当前歌曲信息到控制层(若已在播放) + if (currentSong != null) { + playerController!!.updateSongInfo(currentSong) + playerController!!.updatePlayState(playWhenPrepared) + playerController!!.setVocalMode(originalVocal) + } + lyricCurrent = label("暂无歌词", 22, GOLD) + lyricCurrent!!.setGravity(Gravity.CENTER) + lyricNext = label("", 18, Color.LTGRAY) + lyricNext!!.setGravity(Gravity.CENTER) + right.addView(lyricCurrent, LinearLayout.LayoutParams(-1, dp(42))) + right.addView(lyricNext, LinearLayout.LayoutParams(-1, dp(34))) + val controls = LinearLayout(this) + controls.setGravity(Gravity.CENTER_VERTICAL) + controls.addView( + button("暂停/继续", View.OnClickListener { v: View? -> togglePlay() }), + LinearLayout.LayoutParams(0, dp(50), 1f) + ) + controls.addView( + button("重唱", View.OnClickListener { v: View? -> replay() }), + LinearLayout.LayoutParams(0, dp(50), 1f) + ) + controls.addView( + button("切歌", View.OnClickListener { v: View? -> playNext() }), + LinearLayout.LayoutParams(0, dp(50), 1f) + ) + controls.addView(button("单曲循环", View.OnClickListener { v: View? -> + loopOne = !loopOne + toast(if (loopOne) "单曲循环" else "顺序播放") + }), LinearLayout.LayoutParams(0, dp(50), 1f)) + controls.addView( + button( + vocalActionText(), + View.OnClickListener { v: View? -> toggleOriginalVocal() }), + LinearLayout.LayoutParams(0, dp(50), 1f) + ) + controls.addView( + button( + "音控", + View.OnClickListener { v: View? -> showAudioControlDialog() }), + LinearLayout.LayoutParams(0, dp(50), 1f) + ) + controls.addView( + button( + if (recorder == null) "录音" else "停止录音", + View.OnClickListener { v: View? -> toggleRecording() }), + LinearLayout.LayoutParams(0, dp(50), 1f) + ) + controls.addView( + button( + "状态", + View.OnClickListener { v: View? -> showPlayerStatusDialog() }), + LinearLayout.LayoutParams(0, dp(50), 1f) + ) + right.addView(controls) + right.addView(sectionTitle("已点 " + orderQueue!!.size)) + queueList = listView() + queueAdapter = tvAdapter() + queueList!!.setAdapter(queueAdapter) + queueList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + play( + orderQueue.get(pos) + ) + }) + right.addView(queueList, LinearLayout.LayoutParams(-1, 0, 1f)) + renderQueue() + } + + private fun showSearch() { + showSongs("全部") + } + + fun showSongs(category: String?) { + setPage("点歌") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(230), -1)) + left.addView(sectionTitle("分类")) + // 分类按钮:优先使用数据库语种,回退到本地分类 + val cats: MutableList = ArrayList() + if (library.muse.isAvailable()) { + cats.add("全部") + cats.addAll(library.muse.languages()) + cats.add("本地") + cats.add("网络") + } else { + cats.addAll(library.categories()) + } + for (item in cats) { + left.addView(button(item, View.OnClickListener { v: View? -> + val cat = (v as TextView).getText().toString() + if (library.muse.isAvailable() && ("本地" != cat) && ("网络" != cat)) { + browseMode = "language" + browseParam = cat + browsePage = 0 + loadBrowsePage() + } else { + filterSongs(cat, "") + } + })) + } + left.addView(button("网络同步", View.OnClickListener { v: View? -> fetchCatalog() })) + + val center = panel() + content!!.addView(center, LinearLayout.LayoutParams(0, -1, 1f)) + val searchRow = LinearLayout(this) + searchRow.setGravity(Gravity.CENTER_VERTICAL) + search = EditText(this) + search!!.setSingleLine(true) + search!!.setHint("输入歌名 / 歌手 / 拼音简码") + search!!.setTextColor(Color.WHITE) + search!!.setHintTextColor(Color.GRAY) + search!!.setInputType(InputType.TYPE_CLASS_TEXT) + search!!.setOnEditorActionListener(OnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> + filterSongs(category, search!!.getText().toString()) + true + }) + searchRow.addView(search, LinearLayout.LayoutParams(0, dp(54), 1f)) + searchRow.addView( + button("键盘", View.OnClickListener { v: View? -> showKeyboardDialog() }), + LinearLayout.LayoutParams(dp(110), dp(54)) + ) + searchRow.addView( + button( + "搜索", + View.OnClickListener { v: View? -> + filterSongs( + category, + search!!.getText().toString() + ) + }), LinearLayout.LayoutParams(dp(110), dp(54)) + ) + center.addView(searchRow) + // 分页控制栏 + val pageBar = LinearLayout(this) + pageBar.setGravity(Gravity.CENTER_VERTICAL) + pageBar.addView( + button("上一页", View.OnClickListener { v: View? -> prevPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + pageInfo = label("", 14, GOLD) + pageInfo!!.setGravity(Gravity.CENTER) + pageBar.addView(pageInfo, LinearLayout.LayoutParams(0, dp(44), 1f)) + pageBar.addView( + button("下一页", View.OnClickListener { v: View? -> nextPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + center.addView(pageBar) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + showSongActions(visibleSongs.get(pos)) + true + }) + center.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + // 默认加载热门歌曲第一页 + if (library.muse.isAvailable()) { + browseMode = "hot" + browseParam = "" + browsePage = 0 + loadBrowsePage() + } else { + filterSongs(category, "") + } + } + + private fun showSingers() { + setPage("歌星") + content!!.removeAllViews() + val groupPanel = panel() + content!!.addView(groupPanel, LinearLayout.LayoutParams(dp(190), -1)) + groupPanel.addView(sectionTitle("歌手分类")) + + val singerPanel = panel() + content!!.addView(singerPanel, LinearLayout.LayoutParams(dp(340), -1)) + singerPanel.addView(sectionTitle("歌星列表")) + val singers = listView() + val adapter = tvAdapter() + singers.setAdapter(adapter) + singerPanel.addView(singers, LinearLayout.LayoutParams(-1, 0, 1f)) + + val songs = panel() + content!!.addView(songs, LinearLayout.LayoutParams(0, -1, 1f)) + songs.addView(sectionTitle("歌星歌曲")) + // 分页控制栏 + val pageBar = LinearLayout(this) + pageBar.setGravity(Gravity.CENTER_VERTICAL) + pageBar.addView( + button("上一页", View.OnClickListener { v: View? -> prevPage() }), + LinearLayout.LayoutParams(dp(100), dp(44)) + ) + pageInfo = label("", 13, GOLD) + pageInfo!!.setGravity(Gravity.CENTER) + pageBar.addView(pageInfo, LinearLayout.LayoutParams(0, dp(44), 1f)) + pageBar.addView( + button("下一页", View.OnClickListener { v: View? -> nextPage() }), + LinearLayout.LayoutParams(dp(100), dp(44)) + ) + songs.addView(pageBar) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + showSongActions(visibleSongs.get(pos)) + true + }) + songs.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + singers.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + val singer = adapter.getItem(pos) + if (library.muse.isAvailable()) { + browseMode = "singer" + browseParam = singer + browsePage = 0 + loadBrowsePage() + } else { + visibleSongs.clear() + for (song in library.allSongs()) { + if (isVisibleSong(song) && singerName(song) == singer) visibleSongs.add(song) + } + renderSongs() + } + }) + // 歌手分类:优先使用数据库区域 + if (library.muse.isAvailable()) { + val groups = + arrayOf("全部歌手", "大陆", "港台", "外国", "中国", "男", "女", "组合") + for (group in groups) { + groupPanel.addView( + button( + group, + View.OnClickListener { v: View? -> + populateSingerListFromDb( + adapter, + (v as TextView).getText().toString() + ) + }) + ) + } + populateSingerListFromDb(adapter, "全部歌手") + } else { + val groups = arrayOf( + "全部歌手", + "热门歌手", + "大陆/内地", + "港台", + "日韩", + "欧美", + "组合/乐队", + "其他" + ) + for (group in groups) { + groupPanel.addView( + button( + group, + View.OnClickListener { v: View? -> + populateSingerList( + adapter, + (v as TextView).getText().toString() + ) + }) + ) + } + populateSingerList(adapter, "全部歌手") + } + } + + /** + * 从数据库分页加载歌手列表。 + * + * @param adapter 歌手列表适配器 + * @param group 分类组名(全部歌手/大陆/港台/外国/中国/男/女/组合) + */ + private fun populateSingerListFromDb(adapter: ArrayAdapter, group: String?) { + if (!library.muse.isAvailable()) return + adapter.clear() + busy(true, "正在加载歌手...") + io.execute(Runnable { + var area: String? = null + var type: String? = null + if ("大陆" == group) area = "大陆" + else if ("港台" == group) area = "港台" + else if ("外国" == group) area = "外国" + else if ("中国" == group) area = "中国" + else if ("男" == group) type = "男" + else if ("女" == group) type = "女" + else if ("组合" == group) type = "组合" + val result: MutableList> = library.muse.singers(area, type, 0, 200) + main.post(Runnable { + for (s in result) adapter.add(s[1]) // s[1] 是歌手名 + busy(false, "加载了 " + result.size + " 位歌手") + }) + }) + } + + private fun showLanguages() { + setPage("语种") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(230), -1)) + left.addView(sectionTitle("语种")) + val langs: MutableList = ArrayList() + if (library.muse.isAvailable()) { + langs.add("全部") + langs.addAll(library.muse.languages()) + } else { + langs.addAll(library.languages()) + } + for (language in langs) { + left.addView(button(language, View.OnClickListener { v: View? -> + val lang = (v as TextView).getText().toString() + if (library.muse.isAvailable()) { + browseMode = "language" + browseParam = lang + browsePage = 0 + loadBrowsePage() + } else { + filterLanguage(lang) + } + })) + } + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("语种点歌")) + // 分页控制栏 + val pageBar = LinearLayout(this) + pageBar.setGravity(Gravity.CENTER_VERTICAL) + pageBar.addView( + button("上一页", View.OnClickListener { v: View? -> prevPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + pageInfo = label("", 14, GOLD) + pageInfo!!.setGravity(Gravity.CENTER) + pageBar.addView(pageInfo, LinearLayout.LayoutParams(0, dp(44), 1f)) + pageBar.addView( + button("下一页", View.OnClickListener { v: View? -> nextPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + right.addView(pageBar) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + showSongActions(visibleSongs.get(pos)) + true + }) + right.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + if (library.muse.isAvailable()) { + browseMode = "language" + browseParam = "全部" + browsePage = 0 + loadBrowsePage() + } else { + filterLanguage("全部") + } + } + + private fun showWordCounts() { + setPage("字数") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(230), -1)) + left.addView(sectionTitle("歌名字数")) + val groups = arrayOf("全部", "一字", "二字", "三字", "四字", "五字", "六字以上") + for (group in groups) { + left.addView(button(group, View.OnClickListener { v: View? -> + val g = (v as TextView).getText().toString() + if (library.muse.isAvailable()) { + val wc = if ("一字" == g) 1 else if ("二字" == g) 2 else if ("三字" == g) + 3 + else + if ("四字" == g) 4 else if ("五字" == g) 5 else if ("六字以上" == g) 6 else 0 + browseMode = "wordcount" + browseParam = wc.toString() + browsePage = 0 + loadBrowsePage() + } else { + filterWordCount(g) + } + })) + } + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("字数点歌")) + // 分页控制栏 + val pageBar = LinearLayout(this) + pageBar.setGravity(Gravity.CENTER_VERTICAL) + pageBar.addView( + button("上一页", View.OnClickListener { v: View? -> prevPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + pageInfo = label("", 14, GOLD) + pageInfo!!.setGravity(Gravity.CENTER) + pageBar.addView(pageInfo, LinearLayout.LayoutParams(0, dp(44), 1f)) + pageBar.addView( + button("下一页", View.OnClickListener { v: View? -> nextPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + right.addView(pageBar) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + showSongActions(visibleSongs.get(pos)) + true + }) + right.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + if (library.muse.isAvailable()) { + browseMode = "wordcount" + browseParam = "0" + browsePage = 0 + loadBrowsePage() + } else { + filterWordCount("全部") + } + } + + private fun showRank() { + setPage("排行") + content!!.removeAllViews() + val panel = panel() + content!!.addView(panel, LinearLayout.LayoutParams(-1, -1)) + panel.addView(sectionTitle("热门排行")) + // 分页控制栏 + val pageBar = LinearLayout(this) + pageBar.setGravity(Gravity.CENTER_VERTICAL) + pageBar.addView( + button("上一页", View.OnClickListener { v: View? -> prevPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + pageInfo = label("", 14, GOLD) + pageInfo!!.setGravity(Gravity.CENTER) + pageBar.addView(pageInfo, LinearLayout.LayoutParams(0, dp(44), 1f)) + pageBar.addView( + button("下一页", View.OnClickListener { v: View? -> nextPage() }), + LinearLayout.LayoutParams(dp(110), dp(44)) + ) + panel.addView(pageBar) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + showSongActions(visibleSongs.get(pos)) + true + }) + panel.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + if (library.muse.isAvailable()) { + browseMode = "hot" + browseParam = "" + browsePage = 0 + loadBrowsePage() + } else { + visibleSongs.clear() + for (song in library.allSongs()) { + if (isVisibleSong(song)) visibleSongs.add(song) + } + Collections.sort( + visibleSongs, + Comparator { a: Song?, b: Song? -> Integer.compare(b!!.playCount, a!!.playCount) }) + renderSongs() + } + } + + private fun showQueue() { + setPage("已点") + content!!.removeAllViews() + val panel = panel() + content!!.addView(panel, LinearLayout.LayoutParams(-1, -1)) + val actions = LinearLayout(this) + actions.addView( + button( + "播放第一首", + View.OnClickListener { v: View? -> + if (!orderQueue!!.isEmpty()) play( + orderQueue.get( + 0 + ) + ) + }), LinearLayout.LayoutParams(dp(150), dp(54)) + ) + actions.addView( + button("切歌", View.OnClickListener { v: View? -> playNext() }), + LinearLayout.LayoutParams(dp(110), dp(54)) + ) + actions.addView( + button( + "置顶", + View.OnClickListener { v: View? -> moveSelectedQueueToTop() }), + LinearLayout.LayoutParams(dp(110), dp(54)) + ) + actions.addView( + button("删除", View.OnClickListener { v: View? -> removeSelectedQueue() }), + LinearLayout.LayoutParams(dp(110), dp(54)) + ) + actions.addView(button("清空", View.OnClickListener { v: View? -> + orderQueue!!.clear() + renderQueue() + saveState() + }), LinearLayout.LayoutParams(dp(110), dp(54))) + panel.addView(actions) + queueList = listView() + queueAdapter = tvAdapter() + queueList!!.setAdapter(queueAdapter) + queueList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + play( + orderQueue!!.get(pos) + ) + }) + panel.addView(queueList, LinearLayout.LayoutParams(-1, 0, 1f)) + renderQueue() + } + + private fun showSang() { + setPage("已唱") + content!!.removeAllViews() + val panel = panel() + content!!.addView(panel, LinearLayout.LayoutParams(-1, -1)) + panel.addView(sectionTitle("已唱记录")) + val list = listView() + val adapter = tvAdapter() + for (song in sangHistory) adapter.add(song.displayTitle()) + list.setAdapter(adapter) + panel.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) + } + + private fun showDownloads() { + setPage("下载") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(280), -1)) + left.addView(sectionTitle("下载操作")) + left.addView(button("同步网络曲库", View.OnClickListener { v: View? -> fetchCatalog() })) + left.addView(button("设置网络源", View.OnClickListener { v: View? -> showCatalogDialog() })) + left.addView( + button( + "重扫本地歌曲", + View.OnClickListener { v: View? -> refreshLibrary(null) }) + ) + left.addView( + button( + "取消全部下载", + View.OnClickListener { v: View? -> cancelAllDownloads() }) + ) + left.addView(button("清理已完成", View.OnClickListener { v: View? -> + for (i in downloads.indices.reversed()) { + if ("完成" == downloads.get(i).state) downloads.removeAt(i) + } + showDownloads() + })) + left.addView(button("清理失败任务", View.OnClickListener { v: View? -> + for (i in downloads.indices.reversed()) { + if (downloads.get(i).state.startsWith("失败") || "已取消" == downloads.get(i).state) downloads.removeAt( + i + ) + } + showDownloads() + })) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("下载队列")) + val list = listView() + val adapter = tvAdapter() + for (task in downloads) adapter.add(task.display()) + list.setAdapter(adapter) + list.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + if (pos >= 0 && pos < downloads.size) { + downloads.get(pos).cancelled = true + downloads.get(pos).state = "正在取消" + showDownloads() + } + }) + right.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) + } + + private fun showUdisk() { + setPage("U盘") + content!!.removeAllViews() + val panel = panel() + content!!.addView(panel, LinearLayout.LayoutParams(-1, -1)) + panel.addView(sectionTitle("本地导入")) + panel.addView( + label( + "请把歌曲放入 /sdcard/MaidongKTV/import、/sdcard/MaidongKTV/songs、U盘/KTV 或 U盘/songs,支持 mp4/mkv/avi/mp3/flac/wav。", + 20, + Color.LTGRAY + ) + ) + panel.addView(button("扫描导入目录", View.OnClickListener { v: View? -> + refreshLibrary( + Runnable { showSongs("本地") }) + }), LinearLayout.LayoutParams(dp(180), dp(58))) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + panel.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + filterSongs("本地", "") + } + + fun showSettings() { + currentTabIndex = 8 + setupTabs() + showSettingsPage() + } + + /** + * 退出确认弹窗(对齐原app风格) + */ + private fun AlertDialog.showForTv( + preferredButton: Int = DialogInterface.BUTTON_NEGATIVE, + afterShow: (() -> Unit)? = null, + ): AlertDialog { + val sourcePage = currentPage + val sourceView = this@MainActivity.window.decorView.findFocus() + val sourceFocus = captureFocusBookmark(sourceView) + setOnShowListener { + listView?.apply { + setSelector(R.drawable.bg_tv_dialog_choice_focus) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + defaultFocusHighlightEnabled = false + } + } + window?.decorView?.let(TvFocusStyler::installTree) + listOf( + DialogInterface.BUTTON_NEGATIVE, + DialogInterface.BUTTON_POSITIVE, + DialogInterface.BUTTON_NEUTRAL, + ).mapNotNull(::getButton).forEach(TvFocusStyler::installAction) + val preferred = getButton(preferredButton) + ?: getButton(DialogInterface.BUTTON_NEGATIVE) + ?: getButton(DialogInterface.BUTTON_POSITIVE) + preferred?.let { + if (it.requestFocus() || it.requestFocusFromTouch()) { + it.refreshDrawableState() + it.jumpDrawablesToCurrentState() + it.invalidate() + } + } + afterShow?.invoke() + } + setOnDismissListener { + if (currentPage == sourcePage) restoreFocusBookmark(sourceFocus, sourceView) + } + show() + return this + } + + private fun AlertDialog.Builder.showForTv( + preferredButton: Int = DialogInterface.BUTTON_NEGATIVE, + afterShow: (() -> Unit)? = null, + ): AlertDialog = create().showForTv(preferredButton, afterShow) + + private fun AlertDialog.restoreSourceFocusOnDismiss(): AlertDialog { + val sourcePage = currentPage + val sourceView = this@MainActivity.window.decorView.findFocus() + val sourceFocus = captureFocusBookmark(sourceView) + setOnDismissListener { + if (currentPage == sourcePage) restoreFocusBookmark(sourceFocus, sourceView) + } + return this + } + + private fun showExitDialog() { + AlertDialog.Builder(this) + .setTitle("退出确认") + .setMessage("确定要退出麦动吗?") + .setPositiveButton( + "确定退出", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> finish() }) + .setNegativeButton("取消", null) + .create() + .showForTv(DialogInterface.BUTTON_POSITIVE) + } + + /** 顶栏/键盘相关 占位字段 */ + private val key_abc: TextView? = null + private val key_123: TextView? = null + private val key_hand: TextView? = null + + /** 设置弹窗(对齐原app: 左侧菜单 + 右侧内容) */ + private fun showSettingsDialog() { + val root = getLayoutInflater().inflate(R.layout.dialog_settings, null) + val btnClose = root.findViewById(R.id.btn_settings_close) + val menu = root.findViewById(R.id.settings_menu) + val content = root.findViewById(R.id.settings_content) + + val menuItems = arrayOf( + "🎤 演唱模式", "🔊 音频控制", "🌐 声道设置", "🎚 音调控制", + "📺 画面设置", "📃 跑马灯", "📶 网络设置", "ℹ 关于" + ) + val adapter: ArrayAdapter = + object : ArrayAdapter(this, R.layout.settings_menu_item, menuItems) { + override fun getView(pos: Int, cv: View?, parent: ViewGroup): View { + val v = super.getView(pos, cv, parent) as TextView + v.setTextSize(15f) + v.setMinHeight(dp(44)) + v.setGravity(Gravity.CENTER_VERTICAL) + v.setPadding(dp(16), dp(10), dp(16), dp(10)) + v.setTextColor(Color.rgb(225, 225, 245)) + return v + } + } + menu.setAdapter(adapter) + // 点击左侧菜单渲染右侧内容 + menu.setOnItemClickListener(OnItemClickListener { parent: AdapterView<*>?, view: View?, position: Int, id: Long -> + renderSettingsContent(content, position) + }) + // 默认显示第一个 + renderSettingsContent(content, 0) + + val dialog = AlertDialog.Builder(this) + .setView(root) + .create() + if (dialog.getWindow() != null) { + dialog.getWindow()!!.setBackgroundDrawableResource(android.R.color.transparent) + } + btnClose.setOnClickListener(View.OnClickListener { v: View? -> dialog.dismiss() }) + dialog.restoreSourceFocusOnDismiss() + dialog.setOnShowListener { + menu.post { + if (menu.childCount > 0) menu.getChildAt(0)?.requestFocus() + } + } + dialog.show() + } + + /** 渲染设置弹窗右侧内容 */ + private fun renderSettingsContent(content: LinearLayout, index: Int) { + content.removeAllViews() + when (index) { + 0 -> { + // 演唱模式 + addSettingsTitle(content, "演唱模式") + addSettingsOptions( + content, + "演唱模式", + arrayOf( + "普通演唱", "练唱模式", "评分演唱", "静音练唱" + ), + arrayOf(null, null, "评分演唱", "静音练唱"), + singMode, + OnValueClickListener { v: View?, `val`: Any? -> + setSingMode((`val` as kotlin.String?)!!) + }) + } + + 1 -> { + // 音频控制(链接到调音弹窗) + addSettingsTitle(content, "音频控制") + addSettingsItem( + content, + "麦克风音量", + micVolume.toString() + "%", + View.OnClickListener { v: View? -> showAudioControlDialog() }) + addSettingsItem( + content, + "音乐音量", + musicVolume.toString() + "%", + View.OnClickListener { v: View? -> showAudioControlDialog() }) + addSettingsItem( + content, + "音调", + (if (tone > 0) "+" else "") + tone + " 调", + View.OnClickListener { v: View? -> showAudioControlDialog() }) + addSettingsItem( + content, + "原唱模式", + if (originalVocal) "开" else "关", + View.OnClickListener { v: View? -> toggleOriginalVocal() }) + } + + 2 -> { + // 声道设置 + addSettingsTitle(content, "声道模式") + addSettingsOptions( + content, + "声道", + arrayOf( + "自动", "左伴右原", "右伴左原", "全声道" + ), + arrayOf("自动", "左伴右原", "右伴左原", "全声道"), + vocalChannelMode, + OnValueClickListener { v: View?, `val`: Any? -> + setVocalChannelMode((`val` as kotlin.String?)!!) + }) + } + + 3 -> { + // 音调控制 + addSettingsTitle(content, "音调控制") + addSettingsItem(content, "音调 -", "降调", View.OnClickListener { v: View? -> + tone = clamp(tone - 1, -5, 5) + player?.setTone(tone) + saveState() + }) + addSettingsItem(content, "音调 +", "升调", View.OnClickListener { v: View? -> + tone = clamp(tone + 1, -5, 5) + player?.setTone(tone) + saveState() + }) + } + + 4 -> { + // 画面设置 + addSettingsTitle(content, "画面设置") + addSettingsItem(content, "画面比例", "16:9", null) + addSettingsItem(content, "画面质量", "高", null) + addSettingsItem(content, "全屏模式", "是", null) + } + + 5 -> { + // 跑马灯 + addSettingsTitle(content, "跑马灯") + addSettingsItem(content, "跑马灯状态", "关", null) + addSettingsItem(content, "跑马灯内容", "麦动 欢迎光临", null) + addSettingsItem(content, "滚动速度", "正常", null) + } + + 6 -> { + // 网络设置 + addSettingsTitle(content, "网络设置") + addSettingsItem(content, "当前网络", "WIFI 已连接", null) + addSettingsItem(content, "CDN 主地址", "pub.cdn.cherryonline.cn", null) + addSettingsItem(content, "CDN 备地址", "pub.mcdn.cherryonline.cn", null) + } + + 7 -> { + // 关于 + addSettingsTitle(content, "关于") + addSettingsItem(content, "应用名称", "麦动", null) + addSettingsItem(content, "版本号", "1.0.0", null) + addSettingsItem( + content, + "曲库", + if (library.muse.isAvailable()) "已加载 (" + library.muse.songCount() + " 首)" else "未加载", + null + ) + } + } + } + + /** 设置项标题 */ + private fun addSettingsTitle(parent: LinearLayout, title: String?) { + val tv = TextView(this) + tv.setText(title) + tv.setTextSize(17f) + tv.setTextColor(Color.rgb(0x27, 0xCC, 0xA4)) + tv.setTextSize(17f) + tv.setPadding(dp(8), dp(8), dp(8), dp(12)) + tv.setTypeface(null, Typeface.BOLD) + parent.addView( + tv, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + } + + /** 设置项 - 单行(label + value, 点击有 action) */ + private fun addSettingsItem( + parent: LinearLayout, + label: String?, + value: String?, + onClick: View.OnClickListener? + ) { + val row = LinearLayout(this) + row.setOrientation(LinearLayout.HORIZONTAL) + row.setGravity(Gravity.CENTER_VERTICAL) + row.setBackgroundResource(R.drawable.bg_settings_item) + row.setPadding(dp(14), dp(12), dp(14), dp(12)) + val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT + ) + lp.bottomMargin = dp(8) + parent.addView(row, lp) + val l = TextView(this) + l.setText(label) + l.setTextSize(15f) + l.setTextColor(Color.rgb(225, 225, 245)) + row.addView(l, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + val r = TextView(this) + r.setText(value) + r.setTextSize(15f) + r.setTextColor(Color.rgb(0xFF, 0xD5, 0x6B)) + row.addView( + r, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + if (onClick != null) { + row.setClickable(true) + row.setOnClickListener(onClick) + } + } + + /** 选项值点击回调 (传 value) */ + private fun interface OnValueClickListener { + fun onClick(v: View?, value: Any?) + } + + /** 设置项 - 选项组(单选) */ + private fun addSettingsOptions( + parent: LinearLayout, + title: String?, + options: Array, + values: Array, + current: Any?, + onClick: OnValueClickListener + ) { + val row = LinearLayout(this) + row.setOrientation(LinearLayout.VERTICAL) + row.setBackgroundResource(R.drawable.bg_settings_item) + row.setPadding(dp(14), dp(10), dp(14), dp(10)) + val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT + ) + lp.bottomMargin = dp(8) + parent.addView(row, lp) + val l = TextView(this) + l.setText(title) + l.setTextSize(15f) + l.setTextColor(Color.rgb(225, 225, 245)) + row.addView( + l, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + // 选项按钮横向布局 + val btnRow = LinearLayout(this) + btnRow.setOrientation(LinearLayout.HORIZONTAL) + btnRow.setPadding(0, dp(8), 0, 0) + row.addView( + btnRow, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + for (i in options.indices) { + val opt = options[i] + val `val` = values[i] + val btn = TextView(this) + btn.setText(opt) + val isOn = `val` != null && `val` == current + btn.setBackgroundResource(if (isOn) R.drawable.bg_btn_toggle_on else R.drawable.bg_btn_toggle_off) + btn.setTextColor(if (isOn) Color.WHITE else Color.rgb(204, 204, 204)) + btn.setTextSize(13f) + btn.setGravity(Gravity.CENTER) + btn.setPadding(dp(10), dp(8), dp(10), dp(8)) + val bLp = LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f + ) + bLp.leftMargin = if (i == 0) 0 else dp(4) + bLp.rightMargin = if (i == options.size - 1) 0 else dp(4) + btnRow.addView(btn, bLp) + val fv = `val` + val fi = i + btn.setOnClickListener(View.OnClickListener { v: View? -> + if (fv != null) onClick.onClick(v, fv) + // 更新按钮状态 + for (j in 0 until btnRow.getChildCount()) { + val sib = btnRow.getChildAt(j) as TextView + val isOn2 = j == fi + sib.setBackgroundResource(if (isOn2) R.drawable.bg_btn_toggle_on else R.drawable.bg_btn_toggle_off) + sib.setTextColor(if (isOn2) Color.WHITE else Color.rgb(204, 204, 204)) + } + }) + } + } + + private fun toggleOriginalVocal() { + setOriginalVocalMode(!originalVocal) + } + + /** 按按钮表达的目标模式切换,提示框和实际声道使用同一个目标值。 */ + private fun setOriginalVocalMode(targetOriginal: Boolean) { + val shouldPlay = playWhenPrepared + originalVocal = targetOriginal + applyPlaybackMode() + saveState() + showPlaybackModeNotice(if (targetOriginal) "原唱" else "伴唱") + // 同步状态到控制层 + if (playerController != null) { + playerController!!.setVocalMode(originalVocal) + } + // 更新 TV 布局底部控制栏 + if (textVocalMode != null) { + textVocalMode.setText(vocalActionText()) + } + updateBottomBar(currentSong, shouldPlay) + } + + /** 控制按钮显示下一次动作;播放框提示显示本次切换后实际生效的模式。 */ + private fun vocalActionText(): String = if (originalVocal) "伴唱" else "原唱" + + private fun vocalActionIcon(): Int = if (originalVocal) { + R.drawable.ott_ic_ctrl_music_accomp + } else { + R.drawable.ott_ic_ctrl_music_origin + } + + /** + * 初始化播放器控制层的回调。 + * + * + * 将 [PlayerControllerView] 的按钮事件路由到 MainActivity 的现有播放逻辑: + * + * * `onPlayPause` → [.togglePlay] + * * `onPrev` / `onNext` → [.playPrevSong] / [.playNext] + * * `onVocalSwitch` → 使用 [VocalSwitchHelper] 切换原唱/伴唱声道 + * * `onVolume` → [.showAudioControlDialog] + * * `onFullScreen` → [.showFullScreenPlayer] + * + */ + private fun initPlayerControllerCallbacks() { + if (playerController == null) return + playerController!!.setOnControlListener(object : OnControlListener { + override fun onPlayPause() { + togglePlay() + } + + override fun onPrev() { + playPrevSong() + } + + override fun onNext() { + playNext() + } + + override fun onVocalSwitch(original: Boolean) { + setOriginalVocalMode(original) + } + + override fun onVolume() { + showAudioControlDialog() + } + + override fun onFullScreen() { + showFullScreenPlayer() + } + }) + } + + /** + * 进入全屏播放界面。 + * + * + * 加载 [FullScreenPlayerFragment] 到 `R.id.container` 容器, + * 替换当前 MainFragment,并加入回退栈以便返回。 + */ + fun showFullScreenPlayer() { + if (player == null) player = videoBackground + if (isFullScreen || player == null || fullScreenContainer == null) return + main.removeCallbacks(autoFullscreenRunnable) + playerReturnSurface = player + playerReturnHost = activePlayerHost + playerTouchOverlay?.visibility = View.GONE + + fullScreenContainer!!.removeAllViews() + fullScreenContainer!!.setBackgroundColor(Color.TRANSPARENT) + fullScreenContainer!!.setOnClickListener { + setFullScreenChromeVisible(!fullScreenChromeVisible) + } + + val header = LinearLayout(this) + header.setGravity(Gravity.CENTER_VERTICAL) + header.setPadding(dp(12), dp(4), dp(12), dp(4)) + header.setBackgroundColor(Color.TRANSPARENT) + val logo = ImageView(this) + logo.setImageResource(R.drawable.ktv_logo) + logo.setScaleType(ImageView.ScaleType.FIT_CENTER) + header.addView(logo, LinearLayout.LayoutParams(dp(42), dp(42))) + val song = label(currentAndNextText(), 18, Color.WHITE) + fullScreenSongInfo = song + header.addView(song, LinearLayout.LayoutParams(0, dp(48), 1f)) + val back = label("返回", 15, Color.WHITE) + back.setGravity(Gravity.CENTER) + back.setBackgroundResource(R.drawable.bg_btn_glass) + back.setOnClickListener(View.OnClickListener { v: View? -> exitFullScreenPlayer() }) + back.visibility = View.GONE + header.addView(back, LinearLayout.LayoutParams(dp(76), dp(36))) + val headerParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52), Gravity.TOP + ) + fullScreenContainer!!.addView(header, headerParams) + + val controls = LinearLayout(this) + controls.orientation = LinearLayout.HORIZONTAL + controls.gravity = Gravity.CENTER + controls.clipChildren = false + controls.clipToPadding = false + controls.setPadding(dp(8), dp(4), dp(8), dp(4)) + controls.setBackgroundColor(Color.TRANSPARENT) + val controlItems = ArrayList(7) + + fun addControl(iconRes: Int, text: String, action: () -> Unit): Pair { + val item = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + isFocusable = true + isFocusableInTouchMode = true + setBackgroundColor(Color.TRANSPARENT) + id = View.generateViewId() + clipChildren = false + clipToPadding = false + } + val icon = ImageView(this).apply { + setImageResource(iconRes) + scaleType = ImageView.ScaleType.CENTER_INSIDE + alpha = 0.76f + } + val caption = label(text, 12, Color.rgb(184, 184, 184)).apply { + gravity = Gravity.CENTER + maxLines = 1 + includeFontPadding = false + setPadding(0, 0, 0, 0) + } + item.addView(icon, LinearLayout.LayoutParams(dp(28), dp(30))) + item.addView(caption, LinearLayout.LayoutParams(-1, dp(24))) + item.setOnClickListener { + action() + if (isFullScreen) setFullScreenChromeVisible(true) + } + installPressFeedback(item) + controlItems.add(item) + val params = LinearLayout.LayoutParams(dp(78), dp(68)) + params.setMargins(dp(2), 0, dp(2), 0) + controls.addView(item, params) + return caption to icon + } + + addControl(R.drawable.ic_return_order, "返回点歌") { exitFullScreenPlayer() } + addControl(R.drawable.ott_ic_ctrl_orderlist, "已点") { + exitFullScreenPlayer() + showOrderListPage() + } + addControl( + vocalActionIcon(), + vocalActionText() + ) { + toggleOriginalVocal() + }.also { (text, icon) -> fullScreenVocalText = text; fullScreenVocalIcon = icon } + addControl(R.drawable.ott_ic_ctrl_switch_song, "切歌") { playNext() } + addControl( + if (playWhenPrepared) R.drawable.ott_ic_ctrl_pause else R.drawable.ott_ic_ctrl_play, + if (playWhenPrepared) "暂停" else "播放" + ) { togglePlay() }.also { (text, icon) -> fullScreenPauseText = text; fullScreenPauseIcon = icon } + addControl(R.drawable.ott_ic_ctrl_replay, "重唱") { replay() } + addControl(R.drawable.ott_ic_ctrl_volume, "调音") { showFullScreenVolumeDialog() } + fullScreenToneControl = controlItems.lastOrNull() + + controlItems.forEachIndexed { index, item -> + item.nextFocusLeftId = controlItems.getOrNull(index - 1)?.id ?: item.id + item.nextFocusRightId = controlItems.getOrNull(index + 1)?.id ?: item.id + item.nextFocusUpId = item.id + item.nextFocusDownId = item.id + } + + val controlsParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, dp(74), Gravity.TOP or Gravity.CENTER_HORIZONTAL + ) + controlsParams.topMargin = dp(48) + fullScreenContainer!!.addView(controls, controlsParams) + fullScreenControls = controls + + val progressRow = LinearLayout(this) + progressRow.orientation = LinearLayout.HORIZONTAL + progressRow.gravity = Gravity.CENTER_VERTICAL + progressRow.setPadding(dp(18), dp(4), dp(18), dp(4)) + progressRow.setBackgroundColor(Color.argb(155, 0, 0, 0)) + + fullScreenSeekBar = SeekBar(this).apply { + max = 1 + isFocusable = true + setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + fullScreenTime?.text = formatPlaybackTime(progress) + " / " + + formatPlaybackTime(seekBar?.max ?: 0) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + fullScreenSeeking = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + val position = seekBar?.progress ?: 0 + runCatching { + player?.seekTo(position) + prepareExternalVocal(currentSong, position) + } + fullScreenSeeking = false + updateLyricView() + persistRuntimeState() + } + }) + } + progressRow.addView(fullScreenSeekBar, LinearLayout.LayoutParams(0, dp(48), 1f)) + fullScreenTime = label("00:00 / 00:00", 14, Color.WHITE).apply { + gravity = Gravity.CENTER + } + progressRow.addView(fullScreenTime, LinearLayout.LayoutParams(dp(132), dp(48))) + fullScreenContainer!!.addView( + progressRow, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(58), Gravity.BOTTOM + ) + ) + fullScreenProgressRow = progressRow + + isFullScreen = true + fullScreenContainer!!.setVisibility(View.VISIBLE) + expandPlayerToFullScreen() + updateFullScreenQrOverlay() + setFullScreenChromeVisible(true) + updateFullScreenProgress() + } + + private fun exitFullScreenPlayer() { + if (!isFullScreen || player == null || fullScreenContainer == null) return + fullScreenContainer!!.removeAllViews() + fullScreenContainer!!.setOnClickListener(null) + fullScreenContainer!!.setVisibility(View.GONE) + isFullScreen = false + playerReturnHost?.let { + activePlayerHost = it + applyPlayerBounds(it) + } + playerReturnSurface = null + playerReturnHost = null + fullScreenPauseText = null + fullScreenVocalText = null + fullScreenPauseIcon = null + fullScreenVocalIcon = null + fullScreenSongInfo = null + fullScreenSeekBar = null + fullScreenTime = null + fullScreenSeeking = false + fullScreenControls = null + fullScreenProgressRow = null + fullScreenVolumePanel = null + fullScreenVolumeIndicator = null + fullScreenVolumeMinusButton = null + fullScreenVolumePlusButton = null + fullScreenVolumeMuteButton = null + fullScreenToneControl = null + fullScreenQrOverlay = null + fullScreenChromeVisible = false + main.removeCallbacks(hideFullScreenChromeRunnable) + main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable) + scheduleAutoFullscreen() + } + + private fun setFullScreenChromeVisible(visible: Boolean) { + if (!isFullScreen && !visible) return + fullScreenChromeVisible = visible + fullScreenControls?.visibility = if (visible) View.VISIBLE else View.GONE + fullScreenProgressRow?.visibility = if (visible) View.VISIBLE else View.GONE + if (!visible) fullScreenVolumePanel?.visibility = View.GONE + main.removeCallbacks(hideFullScreenChromeRunnable) + if (visible && isFullScreen) { + val controls = fullScreenControls + if (controls != null && !controls.hasFocus() && fullScreenVolumePanel?.hasFocus() != true) { + controls.getChildAt(0)?.requestFocus() + } + main.postDelayed(hideFullScreenChromeRunnable, 5000L) + } + } + + private fun updateFullScreenProgress() { + if (!isFullScreen || player == null || fullScreenSeekBar == null) return + runCatching { + val duration = max(0, player!!.duration) + val position = max(0, player!!.currentPosition) + if (duration > 0) fullScreenSeekBar!!.max = duration + if (!fullScreenSeeking) fullScreenSeekBar!!.progress = min(position, max(1, duration)) + fullScreenTime?.text = formatPlaybackTime(position) + " / " + formatPlaybackTime(duration) + } + } + + private fun formatPlaybackTime(milliseconds: Int): String { + val totalSeconds = max(0, milliseconds) / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return (if (minutes < 10) "0" else "") + minutes + ":" + + (if (seconds < 10) "0" else "") + seconds + } + + /** + * 供 [FullScreenPlayerFragment] 调用打开音量控制面板。 + */ + fun showAudioControlFromFragment() { + showAudioControlDialog() + } + + private fun showFullScreenVolumeDialog() { + if (!isFullScreen || fullScreenContainer == null) return + val existing = fullScreenVolumePanel + if (existing != null) { + existing.visibility = if (existing.visibility == View.VISIBLE) View.GONE else View.VISIBLE + if (existing.visibility == View.VISIBLE) { + fullScreenVolumeMinusButton?.requestFocus() + } else { + fullScreenToneControl?.requestFocus() + } + return + } + + if (musicVolume > 0) fullScreenPreviousVolume = musicVolume + val panel = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + isClickable = true + } + val indicator = CircularVolumeView(this).apply { + setVolume(musicVolume) + visibility = View.GONE + alpha = 1f + } + panel.addView(indicator, LinearLayout.LayoutParams(dp(154), dp(154)).apply { + bottomMargin = dp(22) + }) + panel.addView(label("调音", 26, Color.WHITE).apply { + gravity = Gravity.CENTER + }, LinearLayout.LayoutParams(dp(420), dp(54))) + + val buttons = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + } + fun volumeButton(text: String): TextView = TextView(this).apply { + this.text = text + id = View.generateViewId() + gravity = Gravity.CENTER + textSize = 18f + setTextColor(Color.WHITE) + setBackgroundResource(R.drawable.ott_bg_volume_ctrl_action_button) + isFocusable = true + isFocusableInTouchMode = true + isClickable = true + includeFontPadding = false + setPadding(dp(14), 0, dp(14), 0) + } + val minus = volumeButton("音量 -") + val plus = volumeButton("音量 +") + val mute = volumeButton("静音").apply { + compoundDrawablePadding = dp(8) + } + fun renderMuteButton() { + mute.text = if (musicVolume == 0) "放音" else "静音" + val drawable = getDrawable( + if (musicVolume == 0) R.drawable.ic_ctrl_sound_enable + else R.drawable.ic_ctrl_sound_disable + ) + drawable?.setBounds(0, 0, dp(23), dp(23)) + mute.setCompoundDrawables(null, null, drawable, null) + } + fun showVolumeResult() { + indicator.animate().cancel() + indicator.setVolume(musicVolume) + indicator.alpha = 1f + indicator.visibility = View.VISIBLE + renderMuteButton() + applySystemMusicVolume() + applyPlaybackMode() + saveState() + main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable) + main.postDelayed(hideFullScreenVolumeIndicatorRunnable, 2200L) + setFullScreenChromeVisible(true) + } + minus.setOnClickListener { + musicVolume = (musicVolume - 5).coerceAtLeast(0) + if (musicVolume > 0) fullScreenPreviousVolume = musicVolume + showVolumeResult() + } + plus.setOnClickListener { + musicVolume = (musicVolume + 5).coerceAtMost(100) + if (musicVolume > 0) fullScreenPreviousVolume = musicVolume + showVolumeResult() + } + mute.setOnClickListener { + if (musicVolume > 0) { + fullScreenPreviousVolume = musicVolume + musicVolume = 0 + } else { + musicVolume = fullScreenPreviousVolume.coerceAtLeast(5) + } + showVolumeResult() + } + listOf(minus, plus, mute).forEachIndexed { index, button -> + button.nextFocusLeftId = listOf(minus, plus, mute).getOrNull(index - 1)?.id ?: button.id + button.nextFocusRightId = listOf(minus, plus, mute).getOrNull(index + 1)?.id ?: button.id + button.nextFocusUpId = fullScreenToneControl?.id ?: button.id + button.nextFocusDownId = button.id + buttons.addView(button, LinearLayout.LayoutParams(dp(136), dp(58)).apply { + leftMargin = dp(6) + rightMargin = dp(6) + }) + } + panel.addView(buttons, LinearLayout.LayoutParams(-2, dp(70)).apply { topMargin = dp(12) }) + renderMuteButton() + + fullScreenContainer!!.addView( + panel, + FrameLayout.LayoutParams(-2, -2, Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM).apply { + bottomMargin = dp(86) + }, + ) + fullScreenVolumePanel = panel + fullScreenVolumeIndicator = indicator + fullScreenVolumeMinusButton = minus + fullScreenVolumePlusButton = plus + fullScreenVolumeMuteButton = mute + minus.requestFocus() + } + + /** + * 切换到上一首歌曲(供控制层 onPrev 回调使用)。 + */ + fun playPrevSong() { + if (orderQueue!!.isEmpty() || currentSong == null) return + val index = orderQueue.indexOf(currentSong!!) + if (index <= 0) { + // 当前歌曲不在队列或已是首曲,从已唱历史取上一首 + if (!sangHistory.isEmpty()) { + val prev = sangHistory.get(0) + orderQueue.add(0, prev) + sangHistory.removeAt(0) + saveState() + play(prev) + } + return + } + val prev = orderQueue.get(index - 1) + // 将当前歌曲放回队首 + orderQueue.removeAt(index) + orderQueue.add(index, prev) + play(prev) + } + + private fun setSingMode(mode: String) { + singMode = mode + if ("练唱模式" == mode) { + recordEnabled = true + scoreEnabled = false + } else if ("评分演唱" == mode) { + recordEnabled = true + scoreEnabled = true + } else if ("静音练唱" == mode) { + recordEnabled = true + scoreEnabled = false + atmosphere = "静音练唱" + } + applyPlaybackMode() + saveState() + toast("演唱模式:" + singMode) + } + + private fun setVocalChannelMode(mode: String) { + vocalChannelMode = mode + applyPlaybackMode() + saveState() + toast("声道模式:" + vocalChannelMode) + } + + /** + * 显示调音/音频控制弹窗(对应"弹窗-调音.jpg") + * 使用 dialog_audio_control.xml 实现 + */ + private fun showAudioControlDialog() { + if (showAlignedTuningDialog()) return + val root = getLayoutInflater().inflate(R.layout.dialog_audio_control, null) + val btnClose = root.findViewById(R.id.btn_audio_close) + val btnStereo = root.findViewById(R.id.btn_tone_stereo) + val btnAccomp = root.findViewById(R.id.btn_tone_accomp) + val btnOrigin = root.findViewById(R.id.btn_tone_origin) + val btnMute = root.findViewById(R.id.btn_tone_mute) + val seekMicVol = root.findViewById(R.id.seek_mic_volume) + val seekMicEffect = root.findViewById(R.id.seek_mic_effect) + val seekMusicVol = root.findViewById(R.id.seek_music_volume) + val tvMicVol = root.findViewById(R.id.text_mic_volume_value) + val tvMicEffect = root.findViewById(R.id.text_mic_effect_value) + val tvMusicVol = root.findViewById(R.id.text_music_volume_value) + val btnDefault = root.findViewById(R.id.btn_audio_default) + val btnSave = root.findViewById(R.id.btn_audio_save) + + // 初始值 + seekMicVol.setProgress(micVolume) + seekMicEffect.setProgress(50) + seekMusicVol.setProgress(musicVolume) + tvMicVol.setText(micVolume.toString()) + tvMicEffect.setText("50") + tvMusicVol.setText(musicVolume.toString()) + + // 4 个切换按钮:伴唱 / 原唱 / 立体声 / 静音 + updateToggleState(btnAccomp, !originalVocal) + updateToggleState(btnOrigin, originalVocal) + updateToggleState(btnStereo, "自动" == vocalChannelMode) + updateToggleState(btnMute, false) + btnAccomp.setOnClickListener(View.OnClickListener { v: View? -> + if (originalVocal) toggleOriginalVocal() + updateToggleState(btnAccomp, true) + updateToggleState(btnOrigin, false) + }) + btnOrigin.setOnClickListener(View.OnClickListener { v: View? -> + if (!originalVocal) toggleOriginalVocal() + updateToggleState(btnAccomp, false) + updateToggleState(btnOrigin, true) + }) + btnStereo.setOnClickListener(View.OnClickListener { v: View? -> + setVocalChannelMode("自动") + updateToggleState(btnStereo, true) + }) + btnMute.setOnClickListener(View.OnClickListener { v: View? -> + if (musicVolume > 0) { + muteBeforeVol = musicVolume + musicVolume = 0 + } else { + musicVolume = muteBeforeVol + } + applySystemMusicVolume() + applyPlaybackMode() + seekMusicVol.setProgress(musicVolume) + tvMusicVol.setText(musicVolume.toString()) + updateToggleState(btnMute, musicVolume == 0) + saveState() + }) + + // 3 个滑块 + seekMicVol.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(s: SeekBar?, p: Int, u: Boolean) { + micVolume = p + tvMicVol.setText(p.toString()) + } + + override fun onStartTrackingTouch(s: SeekBar?) {} + override fun onStopTrackingTouch(s: SeekBar?) { + saveState() + } + }) + seekMicEffect.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(s: SeekBar?, p: Int, u: Boolean) { + tvMicEffect.setText(p.toString()) + } + + override fun onStartTrackingTouch(s: SeekBar?) {} + override fun onStopTrackingTouch(s: SeekBar?) {} + }) + seekMusicVol.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(s: SeekBar?, p: Int, u: Boolean) { + musicVolume = p + tvMusicVol.setText(p.toString()) + } + + override fun onStartTrackingTouch(s: SeekBar?) {} + override fun onStopTrackingTouch(s: SeekBar?) { + applySystemMusicVolume() + applyPlaybackMode() + saveState() + } + }) + + // 默认 / 保存 + btnDefault.setOnClickListener(View.OnClickListener { v: View? -> + seekMicVol.setProgress(60) + seekMicEffect.setProgress(50) + seekMusicVol.setProgress(80) + micVolume = 60 + musicVolume = 80 + applySystemMusicVolume() + applyPlaybackMode() + saveState() + tvMicVol.setText("60") + tvMicEffect.setText("50") + tvMusicVol.setText("80") + updateToggleState(btnMute, false) + }) + + // 创建并显示弹窗 + val dialog = AlertDialog.Builder(this) + .setView(root) + .create() + // 透明背景显示圆角 + if (dialog.getWindow() != null) { + dialog.getWindow()!!.setBackgroundDrawableResource(android.R.color.transparent) + } + btnClose.setOnClickListener(View.OnClickListener { v: View? -> dialog.dismiss() }) + btnSave.setOnClickListener(View.OnClickListener { v: View? -> + saveState() + dialog.dismiss() + }) + dialog.restoreSourceFocusOnDismiss() + dialog.show() + } + + private fun showAlignedTuningDialog(): Boolean { + val root = getLayoutInflater().inflate(R.layout.dialog_tuning, null) + val toneValue = root.findViewById(R.id.text_tone_counter) + val micValue = root.findViewById(R.id.text_mic_counter) + toneValue.setText(tone.toString()) + micValue.setText(micVolume.toString()) + + val dialog = AlertDialog.Builder(this).setView(root).create() + root.findViewById(R.id.btn_tuning_close) + .setOnClickListener(View.OnClickListener { v: View? -> dialog.dismiss() }) + root.findViewById(R.id.btn_tone_down) + .setOnClickListener(View.OnClickListener { v: View? -> + tone = max(-5, tone - 1) + toneValue.setText(tone.toString()) + player?.setTone(tone) + applyPlaybackMode() + saveState() + }) + root.findViewById(R.id.btn_tone_up) + .setOnClickListener(View.OnClickListener { v: View? -> + tone = min(5, tone + 1) + toneValue.setText(tone.toString()) + player?.setTone(tone) + applyPlaybackMode() + saveState() + }) + root.findViewById(R.id.btn_mic_down) + .setOnClickListener(View.OnClickListener { v: View? -> + micVolume = max(0, micVolume - 5) + micValue.setText(micVolume.toString()) + saveState() + }) + root.findViewById(R.id.btn_mic_up) + .setOnClickListener(View.OnClickListener { v: View? -> + micVolume = min(100, micVolume + 5) + micValue.setText(micVolume.toString()) + saveState() + }) + bindAtmospherePreset(root, R.id.btn_preset_pop, "流行") + bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情") + bindAtmospherePreset(root, R.id.btn_preset_rock, "摇滚") + bindAtmospherePreset(root, R.id.btn_preset_singer, "唱将") + + dialog.showForTv(afterShow = { + dialog.window?.apply { + setBackgroundDrawableResource(android.R.color.transparent) + attributes = attributes.apply { dimAmount = 0.72f } + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + root.findViewById(R.id.btn_tone_down).requestFocus() + }) + return true + } + + private fun bindAtmospherePreset(root: View, id: Int, preset: String) { + root.findViewById(id).setOnClickListener(View.OnClickListener { v: View? -> + atmosphere = preset + saveState() + toast("音效: " + preset) + }) + } + + /** 4 个切换按钮的状态切换 */ + private var muteBeforeVol = 80 + private fun updateToggleState(btn: TextView?, on: Boolean) { + if (btn == null) return + btn.setBackgroundResource(if (on) R.drawable.bg_btn_toggle_on else R.drawable.bg_btn_toggle_off) + btn.setTextColor(if (on) Color.WHITE else Color.rgb(204, 204, 204)) + } + + private fun controlRow( + left: String?, + right: String?, + leftAction: Runnable, + rightAction: Runnable + ): LinearLayout { + val row = LinearLayout(this) + row.setGravity(Gravity.CENTER_VERTICAL) + row.addView( + button(left, View.OnClickListener { v: View? -> leftAction.run() }), + LinearLayout.LayoutParams(0, dp(54), 1f) + ) + row.addView( + button(right, View.OnClickListener { v: View? -> rightAction.run() }), + LinearLayout.LayoutParams(0, dp(54), 1f) + ) + return row + } + + private fun showAtmosphereDialog() { + val items = arrayOf("标准", "热烈", "柔和", "派对", "静音练唱") + AlertDialog.Builder(this) + .setTitle("气氛模式") + .setItems( + items, + DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> + atmosphere = items[which] + saveState() + toast("气氛模式:" + atmosphere) + }) + .create().showForTv() + } + + private fun showPlayerStatusDialog() { + val now = if (orderQueue!!.isEmpty()) null else orderQueue.get(0) + val message = ("当前歌曲:" + (if (now == null) "无" else now.displayTitle()) + + "\n已点:" + orderQueue.size + + "\n已唱:" + sangHistory.size + + "\n模式:" + (if (loopOne) "单曲循环" else (if (autoNext) "顺序播放" else "手动切歌")) + + "\n声道:" + (if (originalVocal) "原唱" else "伴唱") + + "\n声道模式:" + vocalChannelMode + + "\n演唱模式:" + singMode + + "\n音乐音量:" + musicVolume + + "\n麦克风音量:" + micVolume + + "\n音调:" + tone + + "\n气氛:" + atmosphere + + "\n评分:" + scoreSummary() + + "\n录音:" + (if (recorder == null) "未录音" else "录音中") + + "\n灯光:" + lightMode + + "\n跑马灯:" + (if (marqueeEnabled) marqueeText else "关闭") + + "\n音画同步:" + audioDelayMs + "ms" + + "\n包房广播:" + (if (tableBroadcastEnabled) tableBroadcastText else "关闭")) + AlertDialog.Builder(this).setTitle("播放状态").setMessage(message) + .setPositiveButton("确定", null).create().showForTv(DialogInterface.BUTTON_POSITIVE) + } + + private fun showStorageDialog() { + val root = library.rootDir + val importRoot = library.importDir + val message = ("歌曲目录:" + root.getAbsolutePath() + + "\n可用空间:" + formatSize(root.getFreeSpace()) + + "\n导入目录:" + importRoot.getAbsolutePath() + + "\n录音目录:" + recordDir().getAbsolutePath() + + "\n曲库总数:" + library.allSongs().size + + "\n下载任务:" + downloads.size) + AlertDialog.Builder(this).setTitle("存储信息").setMessage(message) + .setPositiveButton("确定", null).create().showForTv(DialogInterface.BUTTON_POSITIVE) + } + + private fun showNetworkDialog() { + val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager? + val info = if (cm == null) null else cm.getActiveNetworkInfo() + val message = + ("网络状态:" + (if (info != null && info.isConnected()) "已连接" else "未连接") + + "\n网络类型:" + (if (info == null) "未知" else info.getTypeName()) + + "\n曲库源:" + prefs()!!.getString(KEY_CATALOG_URL, "未设置") + + "\n本地曲库:" + library.allSongs().size + " 首") + AlertDialog.Builder(this) + .setTitle("网络状态") + .setMessage(message) + .setPositiveButton( + "同步曲库", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> fetchCatalog() }) + .setNegativeButton("关闭", null) + .create().showForTv() + } + + + private fun showAppCenter() { + setPage("应用") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(320), -1)) + left.addView(sectionTitle("应用工具")) + left.addView(button("网络设置", View.OnClickListener { v: View? -> showNetworkDialog() })) + left.addView(button("屏幕设置", View.OnClickListener { v: View? -> showScreenDialog() })) + left.addView( + button( + "曲库维护", + View.OnClickListener { v: View? -> showLibraryMaintenance() }) + ) + left.addView(button("存储信息", View.OnClickListener { v: View? -> showStorageDialog() })) + left.addView(button("配置备份", View.OnClickListener { v: View? -> showBackupTools() })) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("本地工具")) + val tools = arrayOf( + "点歌台", + "语种点歌", + "字数点歌", + "歌星分类", + "下载管理", + "U盘加歌", + "用户影片", + "灯光迪斯科", + "手机点歌", + "录音评分" + ) + val grid = GridLayout(this) + grid.setColumnCount(4) + right.addView(grid) + for (tool in tools) { + addTile( + grid, + tool, + "打开", + View.OnClickListener { v: View? -> + openTool( + (v as TextView).getText().toString().split("\n".toRegex()) + .dropLastWhile { it.isEmpty() }.toTypedArray()[0]) + }) + } + right.addView(sectionTitle("本地 APK / 升级包")) + val list = listView() + val adapter = tvAdapter() + val apks = localApks() + for (apk in apks) adapter.add(apk.getName() + " " + formatSize(apk.length())) + if (adapter.isEmpty()) adapter.add("暂无 APK,请放入 /sdcard/MaidongKTV/apps 或 U盘/apps") + list.setAdapter(adapter) + list.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: Long -> + if (pos >= 0 && pos < apks.size) installApk(apks.get(pos)) + }) + right.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) + } + + private fun showTableBroadcast() { + setPage("广播") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(340), -1)) + left.addView(sectionTitle("包房广播")) + left.addView( + button( + if (tableBroadcastEnabled) "广播功能:开" else "广播功能:关", + View.OnClickListener { v: View? -> + tableBroadcastEnabled = !tableBroadcastEnabled + saveState() + showTableBroadcast() + }) + ) + left.addView( + button( + "发送当前广播", + View.OnClickListener { v: View? -> sendTableBroadcast(tableBroadcastText) }) + ) + left.addView(controlRow("时长 -", "时长 +", Runnable { + tableBroadcastSeconds = clamp(tableBroadcastSeconds - 1, 2, 60) + saveState() + showTableBroadcast() + }, Runnable { + tableBroadcastSeconds = clamp(tableBroadcastSeconds + 1, 2, 60) + saveState() + showTableBroadcast() + })) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("广播内容")) + val input = EditText(this) + input.setSingleLine(true) + input.setText(tableBroadcastText) + input.setHint("输入包房广播内容") + right.addView(input, LinearLayout.LayoutParams(-1, dp(58))) + right.addView(button("保存并发送", View.OnClickListener { v: View? -> + tableBroadcastText = input.getText().toString().trim { it <= ' ' } + if (tableBroadcastText!!.isEmpty()) tableBroadcastText = "欢迎光临" + saveState() + sendTableBroadcast(tableBroadcastText) + showTableBroadcast() + }), LinearLayout.LayoutParams(dp(180), dp(58))) + right.addView(label("显示时长:" + tableBroadcastSeconds + " 秒", 20, Color.WHITE)) + right.addView(sectionTitle("广播历史")) + val list = listView() + val adapter = tvAdapter() + if (store.broadcastHistory.isEmpty()) adapter.add("暂无广播记录") + else adapter.addAll(store.broadcastHistory) + list.setAdapter(adapter) + right.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) + } + + private fun showBackupTools() { + setPage("备份") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(340), -1)) + left.addView(sectionTitle("配置备份")) + left.addView(button("备份当前配置", View.OnClickListener { v: View? -> backupConfig() })) + left.addView(button("从备份恢复", View.OnClickListener { v: View? -> restoreConfig() })) + left.addView( + button( + "打开曲库维护", + View.OnClickListener { v: View? -> showLibraryMaintenance() }) + ) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("备份信息")) + val backupDir = backupDir() + right.addView(label("备份目录:" + backupDir.getAbsolutePath(), 20, Color.LTGRAY)) + val list = listView() + val adapter = tvAdapter() + val files = backupDir.listFiles() + if (files != null) { + val values: MutableList = ArrayList() + for (file in files) if (file.isFile()) values.add(file) + Collections.sort( + values, + Comparator { a: File?, b: File? -> + b!!.lastModified().compareTo(a!!.lastModified()) + }) + for (file in values) adapter.add(file.getName() + " " + formatSize(file.length())) + } + if (adapter.isEmpty()) adapter.add("暂无备份") + list.setAdapter(adapter) + right.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) + } + + private fun showLibraryMaintenance() { + setPage("维护") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(340), -1)) + left.addView(sectionTitle("曲库维护")) + left.addView(button("重扫本地曲库", View.OnClickListener { v: View? -> + refreshLibrary( + Runnable { showLibraryMaintenance() }) + })) + left.addView(button("同步网络曲库", View.OnClickListener { v: View? -> fetchCatalog() })) + left.addView(button("清除网络缓存", View.OnClickListener { v: View? -> + val deleted = library.clearRemoteCache() + refreshLibrary(Runnable { + toast(if (deleted) "网络曲库缓存已清除" else "没有可清除的网络缓存") + showLibraryMaintenance() + }) + })) + left.addView(button("清空下载任务", View.OnClickListener { v: View? -> + downloads.clear() + showLibraryMaintenance() + })) + left.addView(button("清理失效已点", View.OnClickListener { v: View? -> + removeMissingQueueItems() + showLibraryMaintenance() + })) + left.addView(button("恢复隐藏歌曲", View.OnClickListener { v: View? -> + store.hiddenSongIds.clear() + saveState() + refreshLibrary(Runnable { + toast("已恢复隐藏歌曲") + showLibraryMaintenance() + }) + })) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("曲库概览")) + right.addView(label("总曲库:" + library.allSongs().size + " 首", 22, Color.WHITE)) + right.addView(label("已点:" + orderQueue!!.size + " 首", 20, Color.LTGRAY)) + right.addView(label("已唱:" + sangHistory.size + " 首", 20, Color.LTGRAY)) + right.addView(label("下载任务:" + downloads.size, 20, Color.LTGRAY)) + right.addView(label("隐藏歌曲:" + store.hiddenSongIds.size + " 首", 20, Color.LTGRAY)) + right.addView(label("本地目录:" + library.rootDir.getAbsolutePath(), 18, Color.LTGRAY)) + right.addView(label("导入目录:" + library.importDir.getAbsolutePath(), 18, Color.LTGRAY)) + } + + private fun showMobileRemoteDialog() { + // 自愈:服务没在跑就先尝试启动(可能因网络切换/瞬时问题停止过)。 + if (!remoteServer.isRunning()) startRemoteServer() + val url = remoteUrl() + if (url.isBlank()) { + val ip = localIp() + val reason = remoteServer.error() ?: "请连接 Wi-Fi 或有线局域网后重试" + val detected = when { + ip.isEmpty() -> "未检测到局域网 IP" + ip == "127.0.0.1" || ip.startsWith("169.254") -> "检测到不可用 IP:$ip" + else -> "检测到 IP:$ip" + } + AlertDialog.Builder(this) + .setTitle("手机点歌") + .setMessage("$reason\n$detected") + .setNegativeButton("关闭", null) + .setPositiveButton("重新检测") { _, _ -> restartRemoteServer() } + .create().showForTv(DialogInterface.BUTTON_POSITIVE) + return + } + val box = LinearLayout(this) + box.setOrientation(LinearLayout.VERTICAL) + box.setGravity(Gravity.CENTER_HORIZONTAL) + box.setPadding(dp(24), dp(18), dp(24), dp(12)) + val qr = ImageView(this) + qr.setBackgroundColor(Color.WHITE) + qr.setImageBitmap(qrBitmap(url, dp(260))) + box.addView(qr, LinearLayout.LayoutParams(dp(280), dp(280))) + val tip = label("手机连接同一局域网后扫码点歌", 18, Color.WHITE) + tip.gravity = Gravity.CENTER + box.addView(tip, LinearLayout.LayoutParams(dp(380), dp(54))) + AlertDialog.Builder(this) + .setTitle("扫码点歌") + .setView(box) + .setPositiveButton("确定", null) + .create().showForTv(DialogInterface.BUTTON_POSITIVE) + } + + private fun restartRemoteServer() { + remoteServer.stop() + startRemoteServer() + updateMobileQrOverlay() + updateFullScreenQrOverlay() + main.postDelayed({ showMobileRemoteDialog() }, 180) + } + + private fun updateMobileQrOverlay() { + val root = findViewById(R.id.layout_root) ?: return + mobileQrOverlay?.let { root.removeView(it) } + mobileQrOverlay = null + if (prefs()?.getBoolean(KEY_SHOW_MOBILE_QR, false) != true) return + val url = remoteUrl() + if (url.isBlank()) return + val box = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(dp(6), dp(6), dp(6), dp(4)) + setBackgroundColor(Color.argb(205, 255, 255, 255)) + addView(ImageView(this@MainActivity).apply { + setImageBitmap(qrBitmap(url, dp(112))) + contentDescription = "手机点歌二维码" + }, LinearLayout.LayoutParams(dp(116), dp(116))) + addView(TextView(this@MainActivity).apply { + text = "手机点歌" + textSize = 13f + gravity = Gravity.CENTER + setTextColor(Color.rgb(59, 18, 92)) + }, LinearLayout.LayoutParams(-1, dp(24))) + setOnClickListener { showMobileRemoteDialog() } + } + val params = FrameLayout.LayoutParams(dp(128), dp(152), Gravity.END or Gravity.BOTTOM).apply { + rightMargin = dp(20) + bottomMargin = dp(76) + } + root.addView(box, params) + mobileQrOverlay = box + } + + /** 全屏播放时固定显示在右上角的小型手机点歌二维码。 */ + private fun updateFullScreenQrOverlay() { + val container = fullScreenContainer ?: return + fullScreenQrOverlay?.let { container.removeView(it) } + fullScreenQrOverlay = null + if (!isFullScreen) return + val url = remoteUrl() + if (url.isBlank()) return + val qrSize = dp(if (compactUi) 68 else 84) + val totalWidth = dp(if (compactUi) 78 else 96) + val box = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(dp(4), dp(4), dp(4), dp(3)) + setBackgroundColor(Color.argb(218, 255, 255, 255)) + elevation = dp(12).toFloat() + addView(ImageView(this@MainActivity).apply { + setImageBitmap(qrBitmap(url, qrSize)) + contentDescription = "扫码手机点歌" + }, LinearLayout.LayoutParams(qrSize, qrSize)) + addView(TextView(this@MainActivity).apply { + text = "扫码点歌" + textSize = if (compactUi) 9f else 10f + gravity = Gravity.CENTER + setTextColor(Color.rgb(59, 18, 92)) + includeFontPadding = false + }, LinearLayout.LayoutParams(-1, dp(if (compactUi) 16 else 18))) + setOnClickListener { showMobileRemoteDialog() } + } + val params = FrameLayout.LayoutParams(totalWidth, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END or Gravity.TOP).apply { + topMargin = dp(8) + rightMargin = dp(10) + } + container.addView(box, params) + box.bringToFront() + fullScreenQrOverlay = box + } + + private fun showMobileRemoteSettings() { + val enabled = prefs()?.getBoolean(KEY_SHOW_MOBILE_QR, false) == true + AlertDialog.Builder(this) + .setTitle("手机点歌") + .setSingleChoiceItems(arrayOf("仅在菜单中显示二维码", "播放页右下角显示二维码"), if (enabled) 1 else 0) { dialog, which -> + prefs()?.edit()?.putBoolean(KEY_SHOW_MOBILE_QR, which == 1)?.apply() + updateMobileQrOverlay() + dialog.dismiss() + } + .setNeutralButton("查看二维码") { _, _ -> showMobileRemoteDialog() } + .setNegativeButton("关闭", null) + .create().showForTv() + } + + private fun startRemoteServer() { + val address = localIp().takeIf { LocalRemoteServer.isPrivateIpv4(it) } + remoteServer.start(address, object : LocalRemoteServer.Callback { + override fun handle( + method: String, + path: String, + query: Map, + body: String + ): LocalRemoteServer.ApiResponse = remoteApi(method, path, query, body) + + override fun statusJson(): String { + try { + val json = JSONObject() + val now = if (orderQueue!!.isEmpty()) null else orderQueue.get(0) + json.put("ok", true) + json.put("current", if (now == null) "" else now.displayTitle()) + json.put("queueSize", orderQueue.size) + json.put("historySize", sangHistory.size) + json.put("playing", player?.isPlaying == true) + json.put("score", scoreSummary()) + json.put("vocal", if (originalVocal) "原唱" else "伴唱") + json.put("vocalChannelMode", vocalChannelMode) + json.put("singMode", singMode) + json.put("remoteUrl", remoteUrl()) + return json.toString() + } catch (e: Exception) { + return errorJson(e) + } + } + + override fun catalogJson(): String { + try { + val array = JSONArray() + for (song in library.allSongs()) { + if (isVisibleSong(song)) array.put(songJson(song)) + } + val json = JSONObject() + json.put("ok", true) + json.put("songs", array) + return json.toString() + } catch (e: Exception) { + return errorJson(e) + } + } + + override fun queueJson(): String { + try { + val queue = JSONArray() + for (song in orderQueue!!) queue.put(songJson(song)) + val history = JSONArray() + for (song in sangHistory) history.put(songJson(song)) + val json = JSONObject() + json.put("ok", true) + json.put("queue", queue) + json.put("history", history) + return json.toString() + } catch (e: Exception) { + return errorJson(e) + } + } + + override fun rankJson(): String { + try { + val ranked: MutableList = ArrayList() + for (song in library.allSongs()) { + if (isVisibleSong(song)) ranked.add(song) + } + Collections.sort( + ranked, + Comparator { a: Song?, b: Song? -> + Integer.compare( + b!!.playCount, + a!!.playCount + ) + }) + val array = JSONArray() + var i = 0 + while (i < ranked.size && i < 80) { + array.put(songJson(ranked.get(i)!!)) + i++ + } + val json = JSONObject() + json.put("ok", true) + json.put("songs", array) + return json.toString() + } catch (e: Exception) { + return errorJson(e) + } + } + + override fun filterJson(type: String?, value: String?): String { + try { + val t = if (type == null) "" else type.trim { it <= ' ' } + val v = if (value == null) "" else value.trim { it <= ' ' } + val array = JSONArray() + for (song in library.allSongs()) { + if (!isVisibleSong(song)) continue + val compare = if ("language" == t) song.language else song.category + if (v.isEmpty() || v == compare) array.put(songJson(song)) + if (array.length() >= 120) break + } + val json = JSONObject() + json.put("ok", true) + json.put("songs", array) + return json.toString() + } catch (e: Exception) { + return errorJson(e) + } + } + + override fun favoritesJson(): String { + try { + val array = JSONArray() + for (song in library.allSongs()) { + if (isVisibleSong(song) && store.isFavorite(song)) array.put(songJson(song)) + } + val json = JSONObject() + json.put("ok", true) + json.put("songs", array) + return json.toString() + } catch (e: Exception) { + return errorJson(e) + } + } + + override fun search(query: String?): String { + try { + val array = JSONArray() + val q = if (query == null) "" else query.lowercase().trim { it <= ' ' } + var count = 0 + for (song in library.allSongs()) { + if (!isVisibleSong(song)) continue + val text = + (song.title + " " + song.singer + " " + song.category + " " + song.language + " " + song.pinyin).lowercase() + if (q.isEmpty() || text.contains(q)) { + array.put(songJson(song)) + if (++count >= 80) break + } + } + val json = JSONObject() + json.put("ok", true) + json.put("songs", array) + return json.toString() + } catch (e: Exception) { + return errorJson(e) + } + } + + override fun order(query: String?): String { + val q = if (query == null) "" else query.lowercase().trim { it <= ' ' } + if (q.isEmpty()) return "{\"ok\":false,\"error\":\"missing q\"}" + for (song in library.allSongs()) { + if (store.hiddenSongIds.contains(stableId(song))) continue + val text = (song.title + " " + song.singer + " " + song.pinyin).lowercase() + if (text.contains(q)) { + main.post(Runnable { handleSong(song) }) + return "{\"ok\":true,\"message\":\"ordered\"}" + } + } + return "{\"ok\":false,\"error\":\"song not found\"}" + } + + override fun command(action: String?, params: Map): String { + val a = if (action == null) "" else action + if ("next" == a) main.post(Runnable { playNext() }) + else if ("pause" == a) main.post(Runnable { togglePlay() }) + else if ("replay" == a) main.post(Runnable { replay() }) + else if ("vocal" == a) { + val mode = params.get("mode") + main.post(Runnable { + originalVocal = "original" == mode || "原唱" == mode + applyPlaybackMode() + saveState() + status!!.setText(if (originalVocal) "远程切换原唱" else "远程切换伴唱") + }) + } else if ("singmode" == a) { + val mode = params.get("mode") + if (mode == null || mode.trim { it <= ' ' } + .isEmpty()) return "{\"ok\":false,\"error\":\"missing mode\"}" + main.post(Runnable { setSingMode(mode.trim { it <= ' ' }) }) + } else if ("channel" == a) { + val mode = params.get("mode") + if (mode == null || mode.trim { it <= ' ' } + .isEmpty()) return "{\"ok\":false,\"error\":\"missing mode\"}" + main.post(Runnable { setVocalChannelMode(mode.trim { it <= ' ' }) }) + } else if ("light" == a) { + val mode = params.get("mode") + if (mode == null || mode.trim { it <= ' ' } + .isEmpty()) return "{\"ok\":false,\"error\":\"missing mode\"}" + main.post(Runnable { + lightMode = mode + saveState() + status!!.setText("远程灯光:" + mode) + }) + } else if ("marquee" == a) { + val text = params.get("text") + main.post(Runnable { + if (text != null && !text.trim { it <= ' ' }.isEmpty()) marqueeText = + text.trim { it <= ' ' } + marqueeEnabled = true + saveState() + status!!.setText("远程跑马灯:" + marqueeText) + }) + } else if ("broadcast" == a) { + val text = params.get("text") + if (text == null || text.trim { it <= ' ' } + .isEmpty()) return "{\"ok\":false,\"error\":\"missing text\"}" + main.post(Runnable { sendTableBroadcast(text.trim { it <= ' ' }) }) + } else if ("status" == a) { + return statusJson() + } else if ("cancel_downloads" == a) { + main.post(Runnable { cancelAllDownloads() }) + } else if ("clear_queue" == a) { + main.post(Runnable { + orderQueue!!.clear() + saveState() + renderQueue() + status!!.setText("远程清空已点") + }) + } else if ("remove_queue" == a) { + val index = params.get("index") + try { + val pos = index!!.toInt() + main.post(Runnable { + if (pos >= 0 && pos < orderQueue!!.size) { + val removed = orderQueue.removeAt(pos) + saveState() + renderQueue() + status!!.setText("远程删除已点:" + removed.title) + } + }) + } catch (e: Exception) { + return "{\"ok\":false,\"error\":\"bad index\"}" + } + } else if ("favorite" == a) { + val q = params.get("q") + if (q == null || q.trim { it <= ' ' } + .isEmpty()) return "{\"ok\":false,\"error\":\"missing q\"}" + for (song in library.allSongs()) { + if (!isVisibleSong(song)) continue + val text = (song.title + " " + song.singer + " " + song.pinyin).lowercase() + if (text.contains(q.lowercase())) { + main.post(Runnable { + store.toggleFavorite(song) + saveState() + status!!.setText("远程收藏:" + song.title) + }) + return "{\"ok\":true,\"message\":\"favorite toggled\"}" + } + } + return "{\"ok\":false,\"error\":\"song not found\"}" + } else if ("hide" == a) { + val q = params.get("q") + if (q == null || q.trim { it <= ' ' } + .isEmpty()) return "{\"ok\":false,\"error\":\"missing q\"}" + for (song in library.allSongs()) { + val text = (song.title + " " + song.singer + " " + song.pinyin).lowercase() + if (text.contains(q.lowercase())) { + main.post(Runnable { removeSong(song) }) + return "{\"ok\":true,\"message\":\"hidden\"}" + } + } + return "{\"ok\":false,\"error\":\"song not found\"}" + } else return "{\"ok\":false,\"error\":\"unknown command\"}" + return "{\"ok\":true}" + } + }) + } + + /** 复刻原版「时光KTV」的 whoami 心跳,维持设备在歌曲源后台的"合法/活跃"状态。 */ + private fun sendDeviceHeartbeat(boot: Boolean) { + Thread({ + try { + val now = System.currentTimeMillis() + val sb = StringBuilder("http://hbeat.ktvsky.com/whoami?_src=600007&_src_at=600001") + sb.append("&_m=").append(java.net.URLEncoder.encode(Build.MODEL, "UTF-8")) + sb.append("&_mac=").append(LEGACY_DEVICE_ID) + sb.append("&_vc=4&_vn=4.1.3.03281430") + sb.append("&_pkn=").append(LEGACY_PACKAGE) + sb.append("&_player=0&_boot=").append(if (boot) 1 else 0) + sb.append("&_ts=").append(now) + sb.append("&_api=").append(Build.VERSION.SDK_INT) + sb.append("&ktv_id=").append(LEGACY_KTV_ID) + sb.append("&androidid=").append(LEGACY_DEVICE_ID) + sb.append("&_manuf=").append(java.net.URLEncoder.encode(Build.MANUFACTURER, "UTF-8")) + sb.append("&_cid=A_").append(LEGACY_DEVICE_ID) + sb.append("&d_name=").append(java.net.URLEncoder.encode(Build.PRODUCT, "UTF-8")) + sb.append("&d_device=").append(java.net.URLEncoder.encode(Build.DEVICE, "UTF-8")) + sb.append("&d_board=").append(java.net.URLEncoder.encode(Build.BOARD, "UTF-8")) + sb.append("&d_hardware=").append(java.net.URLEncoder.encode(Build.HARDWARE, "UTF-8")) + sb.append("&d_fingerprint=").append(java.net.URLEncoder.encode(Build.FINGERPRINT, "UTF-8")) + sb.append("&d_incremental=").append(java.net.URLEncoder.encode(Build.VERSION.INCREMENTAL, "UTF-8")) + sb.append("&d_release=").append(java.net.URLEncoder.encode(Build.VERSION.RELEASE, "UTF-8")) + sb.append("&d_utc=").append(now).append("&d_user=dpi") + val conn = (URL(sb.toString()).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = 8000 + readTimeout = 8000 + setRequestProperty("Content-Type", "text/plain; charset=utf-8") + } + try { + conn.outputStream.use { it.write(ByteArray(0)) } + Log.i(TAG, "whoami heartbeat HTTP ${conn.responseCode}") + } finally { + conn.disconnect() + } + } catch (ignored: Exception) { + } + }, "ktv-heartbeat").start() + } + + /** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */ + private fun reportDeviceBootInfo() { + Thread({ + try { + val sb = StringBuilder() + sb.append("BOOT pkg=").append(packageName) + sb.append(" ver=").append(BuildConfig.VERSION_NAME).append('(').append(BuildConfig.VERSION_CODE).append(')') + sb.append(" device=").append(Build.BRAND).append(' ').append(Build.MODEL) + sb.append(" android=").append(Build.VERSION.RELEASE).append("(api").append(Build.VERSION.SDK_INT).append(')') + sb.append(" abi=").append(Build.SUPPORTED_ABIS?.joinToString(",")) + // 歌曲源设备身份(与抓包请求一致) + sb.append(" mac=").append(LEGACY_DEVICE_ID).append(" ktv=").append(LEGACY_KTV_ID) + sb.append(" ip=").append(localIp()) + // 网关 / DNS / WiFi 基础信息 + try { + val wm = getSystemService(WIFI_SERVICE) as android.net.wifi.WifiManager + val info = wm.connectionInfo + if (info != null) { + val ssid = info.ssid?.replace("\"", "") + sb.append(" wifi=").append(ssid) + sb.append(" rssi=").append(info.rssi) + } + } catch (ignored: Exception) { + } + try { + val dm = getSystemService(CONNECTIVITY_SERVICE) as android.net.ConnectivityManager + val network = dm.activeNetwork + if (network != null) { + sb.append(" net=").append(dm.getNetworkCapabilities(network)?.toString().orEmpty()) + } + } catch (ignored: Exception) { + } + sb.append(" bootTs=").append(System.currentTimeMillis()) + MiliLog.notice(sb.toString()) + } catch (ignored: Exception) { + } + }, "ktv-boot-log").start() + } + + private fun remoteApi( + method: String, + path: String, + query: Map, + body: String + ): LocalRemoteServer.ApiResponse { + return try { + when { + method == "GET" && path == "/api/v1/state" -> remoteOk(remoteStateJson()) + method == "GET" && path == "/api/v1/songs" -> remoteOk(remoteSearchJson(query)) + method == "GET" && path == "/api/v1/hot" -> remoteOk(remoteHotJson(query)) + method == "GET" && path == "/api/v1/queue" -> remoteOk(remoteQueueJson()) + method == "POST" && path == "/api/v1/queue" -> remoteOrder(JSONObject(body)) + method == "POST" && path == "/api/v1/player/actions" -> remotePlayerAction(JSONObject(body)) + method == "PATCH" && path == "/api/v1/player/audio" -> remoteAudio(JSONObject(body)) + path.startsWith("/api/v1/queue/") && method in arrayOf("PATCH", "DELETE") -> { + remoteQueueMutation(method, URLDecoder.decode(path.substringAfterLast('/'), "UTF-8")) + } + else -> remoteError(404, "NOT_FOUND", "接口不存在") + } + } catch (e: Exception) { + remoteError(400, "BAD_REQUEST", e.message ?: "请求参数错误") + } + } + + private fun remoteStateJson(): JSONObject { + val now = currentSong ?: orderQueue?.firstOrNull() + val next = orderQueue?.firstOrNull { now == null || stableId(it) != stableId(now) } + return JSONObject().apply { + put("current", now?.let { songJson(it) } ?: JSONObject.NULL) + put("next", next?.let { songJson(it) } ?: JSONObject.NULL) + put("queueSize", orderQueue?.size ?: 0) + put("playing", player?.isPlaying == true) + put("vocalMode", if (originalVocal) "original" else "accompany") + put("musicVolume", musicVolume) + put("micVolume", micVolume) + } + } + + private fun remoteSearchJson(query: Map): JSONObject { + val keyword = query["q"].orEmpty().trim() + val type = query["type"].orEmpty().lowercase(Locale.ROOT).let { if (it == "song") "song" else "singer" } + val page = (query["page"]?.toIntOrNull() ?: 1).coerceAtLeast(1) + val pageSize = (query["pageSize"]?.toIntOrNull() ?: 20).coerceIn(1, 50) + val offset = (page - 1) * pageSize + val songs = if (library.muse.isAvailable() && keyword.isNotBlank()) { + if (type == "singer") library.muse.searchSongsBySingerKeyword(keyword, offset, pageSize + 1) + else library.muse.searchSongs(keyword, offset, pageSize + 1) + } else { + library.allSongs().asSequence().filter { song -> + isVisibleSong(song) && keyword.isNotBlank() && if (type == "singer") { + song.singer.orEmpty().contains(keyword, true) + } else { + (song.title + " " + song.pinyin).contains(keyword, true) + } + }.drop(offset).take(pageSize + 1).toMutableList() + } + val hasMore = songs.size > pageSize + val pageSongs = songs.take(pageSize) + val array = JSONArray() + pageSongs.forEach { song -> + remoteSongCache[stableId(song)] = song + array.put(songJson(song)) + } + while (remoteSongCache.size > 500) remoteSongCache.remove(remoteSongCache.keys.first()) + return JSONObject().put("songs", array).put("page", page).put("pageSize", pageSize) + .put("type", type).put("hasMore", hasMore) + } + + private fun remoteHotJson(query: Map): JSONObject { + val page = (query["page"]?.toIntOrNull() ?: 1).coerceAtLeast(1) + val pageSize = (query["pageSize"]?.toIntOrNull() ?: 10).coerceIn(1, 30) + val offset = (page - 1) * pageSize + val songs = if (library.muse.isAvailable()) { + library.muse.hotSongs(offset, pageSize + 1) + } else { + library.allSongs().asSequence().filter(::isVisibleSong) + .sortedByDescending { it.playCount }.drop(offset).take(pageSize + 1).toMutableList() + } + val hasMore = songs.size > pageSize + val array = JSONArray() + songs.take(pageSize).forEach { song -> + remoteSongCache[stableId(song)] = song + array.put(songJson(song)) + } + while (remoteSongCache.size > 500) remoteSongCache.remove(remoteSongCache.keys.first()) + return JSONObject().put("songs", array).put("page", page).put("pageSize", pageSize) + .put("hasPrevious", page > 1).put("hasMore", hasMore) + } + + private fun remoteQueueJson(): JSONObject { + val array = JSONArray() + orderQueue?.toList()?.forEach { array.put(songJson(it)) } + return JSONObject().put("queue", array) + } + + private fun remoteOrder(json: JSONObject): LocalRemoteServer.ApiResponse { + val id = json.optString("songId").trim() + if (id.isEmpty()) return remoteError(400, "MISSING_SONG_ID", "缺少歌曲 ID") + val song = remoteSongCache[id] ?: library.allSongs().firstOrNull { stableId(it) == id } + ?: return remoteError(404, "SONG_NOT_FOUND", "歌曲不存在或搜索结果已过期") + if (orderQueue?.any { stableId(it) == id } == true) { + return remoteError(409, "ALREADY_QUEUED", "歌曲已在已点列表中") + } + main.post { + addToQueue(song) + remoteStateVersion++ + saveState() + } + return remoteOk(JSONObject().put("message", "点歌成功"), 201) + } + + private fun remoteQueueMutation(method: String, id: String): LocalRemoteServer.ApiResponse { + val song = orderQueue?.firstOrNull { stableId(it) == id } + ?: return remoteError(404, "QUEUE_ITEM_NOT_FOUND", "已点歌曲不存在") + if (stableId(currentSong ?: song) == id && currentSong != null) { + return remoteError(409, "CURRENT_SONG", "正在播放的歌曲不能修改") + } + main.post { + if (method == "PATCH") moveQueuedSongToNext(song) else deleteQueuedSong(song) + remoteStateVersion++ + } + return remoteOk(JSONObject().put("message", if (method == "PATCH") "已置顶" else "已删除")) + } + + private fun remotePlayerAction(json: JSONObject): LocalRemoteServer.ApiResponse { + val action = json.optString("action") + if (action !in arrayOf("pause", "resume", "next", "replay")) { + return remoteError(400, "BAD_ACTION", "不支持的播放操作") + } + main.post { + when (action) { + "pause" -> if (player?.isPlaying == true) togglePlay() + "resume" -> if (player?.isPlaying != true) togglePlay() + "next" -> playNext() + "replay" -> replay() + } + remoteStateVersion++ + } + return remoteOk(JSONObject().put("message", "操作已执行")) + } + + private fun remoteAudio(json: JSONObject): LocalRemoteServer.ApiResponse { + if (!json.has("vocalMode") && !json.has("musicVolume") && !json.has("micVolume")) { + return remoteError(400, "MISSING_AUDIO_VALUE", "缺少音频参数") + } + val vocal = json.optString("vocalMode", "") + if (vocal.isNotEmpty() && vocal !in arrayOf("original", "accompany")) { + return remoteError(400, "BAD_VOCAL_MODE", "原伴唱参数无效") + } + val music = if (json.has("musicVolume")) json.optInt("musicVolume", -1) else null + val mic = if (json.has("micVolume")) json.optInt("micVolume", -1) else null + if ((music != null && music !in 0..100) || (mic != null && mic !in 0..100)) { + return remoteError(400, "BAD_VOLUME", "音量必须在 0 到 100 之间") + } + main.post { + if (vocal.isNotEmpty()) originalVocal = vocal == "original" + music?.let { musicVolume = it } + mic?.let { micVolume = it } + applyPlaybackMode() + playerController?.setVocalMode(originalVocal) + saveState() + remoteStateVersion++ + } + return remoteOk(JSONObject().put("message", "音频设置已更新")) + } + + private fun remoteOk(data: JSONObject, statusCode: Int = 200): LocalRemoteServer.ApiResponse { + data.put("ok", true).put("stateVersion", remoteStateVersion) + return LocalRemoteServer.ApiResponse(statusCode, data.toString()) + } + + private fun remoteError(statusCode: Int, code: String, message: String): LocalRemoteServer.ApiResponse = + LocalRemoteServer.ApiResponse(statusCode, JSONObject().put("ok", false).put("code", code) + .put("error", message).put("stateVersion", remoteStateVersion).toString()) + + private fun errorJson(e: Exception): String { + return "{\"ok\":false,\"error\":\"" + e.message.toString().replace("\"", "'") + "\"}" + } + + @Throws(Exception::class) + private fun songJson(song: Song): JSONObject { + val item = JSONObject() + item.put("id", stableId(song)) + item.put("title", song.title) + item.put("singer", song.singer) + item.put("category", song.category) + item.put("language", song.language) + item.put("remote", song.remote) + item.put("favorite", store.isFavorite(song)) + item.put("playCount", song.playCount) + item.put("hasOriginal", hasUrl(song.originalUrl) || hasUrl(song.originalPath)) + item.put("hasAccompany", hasUrl(song.accompanyUrl) || hasUrl(song.accompanyPath)) + item.put("hasLyric", hasUrl(song.lyricUrl) || hasUrl(song.lyricPath)) + return item + } + + private fun qrBitmap(text: String, size: Int): Bitmap { + try { + val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size) + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565) + for (y in 0 until size) { + for (x in 0 until size) { + bitmap.setPixel(x, y, if (matrix.get(x, y)) Color.BLACK else Color.WHITE) + } + } + return bitmap + } catch (e: Exception) { + val fallback = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565) + fallback.eraseColor(Color.WHITE) + return fallback + } + } + + private fun remoteUrl(): String { + if (!remoteServer.isRunning() || remoteServer.port() <= 0) return "" + val overrideHost = Settings.Global.getString(contentResolver, "maidong_remote_host") + ?.takeIf { LocalRemoteServer.isPrivateIpv4(it) } + val host = overrideHost ?: remoteServer.address() ?: return "" + return "http://$host:${remoteServer.port()}/mobile" + } + + /** + * 选择"扫码点歌"应公布的局域网 IP。手机(如 S10)常有蜂窝(rmnet/10.x)、 + * 抓包代理(VPN tun)等多接口,若随机选到这些会被手机扫描后无法访问, + * 导致扫码点歌连不上。这里按可靠性排序: + * 1) 首选 up、非虚拟接口上的站点本地 IPv4(优先 wlan/eth 网卡); + * 2) 退而求其次选任意非虚拟接口的私有 IPv4; + * 3) 再选任意私有 IPv4(兼容个别厂商网卡命名); + * 4) 都找不到返回空串(不返回 127.0.0.1,避免生成只能本机访问的无效二维码)。 + */ + private fun localIp(): String { + var firstPrivate = "" + var firstNonVirtual = "" + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val network = interfaces.nextElement() + val name = network.name.orEmpty().lowercase(Locale.ROOT) + val virtual = network.isVirtual || name.startsWith("tun") || name.startsWith("ppp") || + name.startsWith("wg") || name.startsWith("rmnet") || name.startsWith("radio") || + name.startsWith("ccmni") || name.startsWith("pdp") || name.contains("vpn") + val up = network.isUp && !network.isLoopback + val addresses = network.getInetAddresses() + while (addresses.hasMoreElements()) { + val address = addresses.nextElement() + if (address.isLoopbackAddress || address.isLinkLocalAddress) continue + val host = address.hostAddress ?: continue + if (host.indexOf(':') >= 0 || !LocalRemoteServer.isPrivateIpv4(host)) continue + if (firstPrivate.isEmpty()) firstPrivate = host + if (up && !virtual && firstNonVirtual.isEmpty()) firstNonVirtual = host + if (up && !virtual && (name.startsWith("wlan") || name.startsWith("eth") || name.startsWith("en"))) { + return host + } + } + } + } catch (ignored: Exception) { + } + return firstNonVirtual.ifEmpty { firstPrivate } + } + + private fun showScoreDialog() { + val box = LinearLayout(this) + box.setOrientation(LinearLayout.VERTICAL) + box.addView(label("当前:" + (if (scoreEnabled) "已开启" else "已关闭"), 20, Color.WHITE)) + box.addView(label("最近:" + scoreSummary(), 20, Color.LTGRAY)) + box.addView(sectionTitle("评分记录")) + val list = listView() + val adapter = tvAdapter() + if (store.scoreHistory.isEmpty()) adapter.add("暂无评分记录") + else adapter.addAll(store.scoreHistory) + list.setAdapter(adapter) + box.addView(list, LinearLayout.LayoutParams(-1, dp(260))) + AlertDialog.Builder(this) + .setTitle("演唱评分") + .setView(box) + .setPositiveButton( + if (scoreEnabled) "关闭评分" else "开启评分", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + scoreEnabled = !scoreEnabled + saveState() + }) + .setNegativeButton("确定", null) + .create().showForTv() + } + + private fun showRecordings() { + setPage("录音") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(280), -1)) + left.addView(sectionTitle("录音操作")) + left.addView( + button( + if (recorder == null) "开始录音" else "停止录音", + View.OnClickListener { v: View? -> toggleRecording() }) + ) + left.addView(button("刷新列表", View.OnClickListener { v: View? -> showRecordings() })) + left.addView( + button( + if (recordEnabled) "录音功能:开" else "录音功能:关", + View.OnClickListener { v: View? -> + recordEnabled = !recordEnabled + if (!recordEnabled) stopRecording(true) + saveState() + showRecordings() + }) + ) + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("录音文件")) + val list = listView() + val adapter = tvAdapter() + val files = recordDir().listFiles() + if (files != null) { + val values: MutableList = ArrayList() + for (file in files) if (file.isFile()) values.add(file) + Collections.sort( + values, + Comparator { a: File?, b: File? -> + b!!.lastModified().compareTo(a!!.lastModified()) + }) + for (file in values) adapter.add(file.getName() + " " + formatSize(file.length())) + } + if (adapter.isEmpty()) adapter.add("暂无录音") + list.setAdapter(adapter) + right.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) + } + + private fun showMarqueeSettings() { + val box = LinearLayout(this) + box.setOrientation(LinearLayout.VERTICAL) + val input = EditText(this) + input.setSingleLine(true) + input.setText(marqueeText) + input.setHint("跑马灯文字") + box.addView(label("当前:" + (if (marqueeEnabled) "已开启" else "已关闭"), 20, Color.WHITE)) + box.addView(input, LinearLayout.LayoutParams(-1, dp(58))) + AlertDialog.Builder(this) + .setTitle("跑马灯设置") + .setView(box) + .setPositiveButton( + "保存", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + marqueeText = input.getText().toString().trim { it <= ' ' } + if (marqueeText!!.isEmpty()) marqueeText = "欢迎使用麦动" + saveState() + toast("跑马灯已保存") + }) + .setNegativeButton( + if (marqueeEnabled) "关闭" else "开启", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + marqueeEnabled = !marqueeEnabled + saveState() + }) + .create().showForTv() + } + + private fun showDisco() { + setPage("迪斯科") + content!!.removeAllViews() + val panel = panel() + content!!.addView(panel, LinearLayout.LayoutParams(-1, -1)) + panel.addView(sectionTitle("灯光 / 迪斯科")) + val modes = + arrayOf("自动", "开房灯", "关房灯", "暂停灯", "静音灯", "热舞", "柔和", "全关") + val grid = GridLayout(this) + grid.setColumnCount(4) + panel.addView(grid, LinearLayout.LayoutParams(-1, 0, 1f)) + for (mode in modes) { + addTile( + grid, + mode, + if (mode == lightMode) "当前模式" else "切换灯效", + View.OnClickListener { v: View? -> + lightMode = (v as TextView).getText().toString().split("\n".toRegex()) + .dropLastWhile { it.isEmpty() }.toTypedArray()[0] + if ("热舞" == lightMode) atmosphere = "派对" + if ("柔和" == lightMode) atmosphere = "柔和" + saveState() + showDisco() + }) + } + panel.addView( + label( + "本地版记录灯效状态;接入实际灯控盒时可把这些模式映射到串口、HTTP 或红外命令。", + 18, + Color.LTGRAY + ) + ) + } + + private fun showLocalMovies() { + setPage("本地影片") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(300), -1)) + left.addView(sectionTitle("影片操作")) + left.addView(button("扫描影片目录", View.OnClickListener { v: View? -> showLocalMovies() })) + left.addView(label("目录:/sdcard/MaidongKTV/movies 或 U盘/movies", 18, Color.LTGRAY)) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("用户 MV / 本地影片")) + val list = listView() + val adapter = tvAdapter() + val movies = localMovies() + for (file in movies) adapter.add(file.getName() + " " + formatSize(file.length())) + if (adapter.isEmpty()) adapter.add("暂无影片") + list.setAdapter(adapter) + list.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: kotlin.Long -> + if (pos >= 0 && pos < movies.size) playMovie(movies.get(pos)) + }) + right.addView(list, LinearLayout.LayoutParams(-1, 0, 1f)) + } + + private fun networkSummary(): String { + val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager? + val info = if (cm == null) null else cm.getActiveNetworkInfo() + val wifi = getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager? + val wifiState = + if (wifi == null) "未知" else (if (wifi.isWifiEnabled()) "已开启" else "已关闭") + return ("连接:" + (if (info != null && info.isConnected()) "已连接" else "未连接") + + "\n类型:" + (if (info == null) "未知" else info.getTypeName()) + + "\nWiFi:" + wifiState + + "\nIP:" + localIp()) + } + + private fun testCatalogSource() { + val url: String = prefs()!!.getString(KEY_CATALOG_URL, "")!! + if (url.trim { it <= ' ' }.isEmpty()) { + showCatalogDialog() + return + } + busy(true, "正在测试曲库源...") + io.execute(Runnable { + try { + val array = CatalogClient().fetch(url) + main.post(Runnable { busy(false, "曲库源可用:" + array.length() + " 首") }) + } catch (e: Exception) { + main.post(Runnable { busy(false, "曲库源不可用:" + e.message) }) + } + }) + } + + private fun openTool(tool: String?) { + if ("点歌台" == tool) showSongs("全部") + else if ("语种点歌" == tool) showLanguages() + else if ("字数点歌" == tool) showWordCounts() + else if ("歌星分类" == tool) showSingers() + else if ("下载管理" == tool) showDownloads() + else if ("U盘加歌" == tool) showUdisk() + else if ("用户影片" == tool) showLocalMovies() + else if ("灯光迪斯科" == tool) showDisco() + else if ("手机点歌" == tool) showMobileRemoteDialog() + else if ("录音评分" == tool) showScoreDialog() + } + + private fun sendTableBroadcast(text: String?) { + if (!tableBroadcastEnabled) { + toast("包房广播已关闭") + return + } + var value: String? = if (text == null) "" else text.trim { it <= ' ' } + if (value!!.isEmpty()) value = tableBroadcastText + if (value == null || value.trim { it <= ' ' }.isEmpty()) value = "欢迎光临" + tableBroadcastText = value + marqueeEnabled = true + marqueeText = value + val stamp = SimpleDateFormat("HH:mm:ss", Locale.ROOT).format(Date()) + store.broadcastHistory.add(0, stamp + " " + value) + while (store.broadcastHistory.size > 100) store.broadcastHistory.removeAt(store.broadcastHistory.size - 1) + saveState() + status!!.setText("包房广播:" + value + "(" + tableBroadcastSeconds + "秒)") + toast("广播已发送") + } + + private fun applyScreenBrightness() { + val attrs = getWindow().getAttributes() + attrs.screenBrightness = screenBrightness / 100f + getWindow().setAttributes(attrs) + } + + private fun removeMissingQueueItems() { + val before = orderQueue!!.size + for (i in orderQueue.indices.reversed()) { + val song = orderQueue.get(i) + val localPath = song.path + if (!song.remote && (localPath.isNullOrEmpty() || !File(localPath).exists())) { + orderQueue.removeAt(i) + } + } + saveState() + toast("已清理:" + (before - orderQueue.size) + " 首") + } + + private fun toggleRecording() { + if (!recordEnabled) { + toast("录音功能已关闭") + return + } + if (recorder == null) startRecording() + else stopRecording(true) + } + + private fun startRecording() { + if (Build.VERSION.SDK_INT >= 23 + && checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 11) + return + } + try { + val dir = recordDir() + if (!dir.exists()) dir.mkdirs() + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(Date()) + val now = if (orderQueue!!.isEmpty()) null else orderQueue.get(0) + val name = + (if (now == null) "ktv" else safeFileName(now.title + "-" + now.singer)) + "-" + stamp + ".m4a" + currentRecording = File(dir, name) + recorder = MediaRecorder() + recorder!!.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder!!.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder!!.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder!!.setOutputFile(currentRecording!!.getAbsolutePath()) + recorder!!.prepare() + recorder!!.start() + toast("开始录音") + status!!.setText("正在录音:" + currentRecording!!.getName()) + if ("首页" == currentPage) showHome() + } catch (e: Exception) { + stopRecording(false) + toast("录音启动失败:" + e.message) + } + } + + private fun stopRecording(notify: Boolean) { + if (recorder == null) return + try { + recorder!!.stop() + } catch (ignored: Exception) { + } + try { + recorder!!.release() + } catch (ignored: Exception) { + } + recorder = null + if (notify && currentRecording != null) toast("录音已保存:" + currentRecording!!.getName()) + currentRecording = null + if ("录音" == currentPage) showRecordings() + else if ("首页" == currentPage) showHome() + } + + private fun recordScore(song: Song?) { + if (!scoreEnabled || song == null) return + val value = 60 + abs((stableId(song) + System.currentTimeMillis() / 60000L).hashCode() % 40) + store.lastScore = value + store.lastScoreSong = song.displayTitle() + store.scoreHistory.add(0, value.toString() + " 分 " + song.displayTitle()) + while (store.scoreHistory.size > 100) store.scoreHistory.removeAt(store.scoreHistory.size - 1) + } + + private fun scoreSummary(): String { + if (store.lastScore <= 0 || store.lastScoreSong == null || store.lastScoreSong.isEmpty()) return "暂无评分" + return store.lastScore.toString() + " 分 " + store.lastScoreSong + } + + private fun recordDir(): File { + return AppPaths.recordsDir + } + + private fun localMovies(): MutableList { + val out: MutableList = ArrayList() + scanMovieDir( + AppPaths.moviesDir, + out + ) + val storage = File("/storage") + val roots = storage.listFiles() + if (roots != null) { + for (root in roots) { + val name = root.getName() + if ("emulated" == name || "self" == name) continue + scanMovieDir(File(root, "movies"), out) + scanMovieDir(File(root, "Movies"), out) + } + } + Collections.sort( + out, + Comparator { a: File?, b: File? -> + a!!.getName().compareTo(b!!.getName(), ignoreCase = true) + }) + return out + } + + private fun localApks(): MutableList { + val out: MutableList = ArrayList() + scanApkDir(AppPaths.appsDir, out) + val storage = File("/storage") + val roots = storage.listFiles() + if (roots != null) { + for (root in roots) { + val name = root.getName() + if ("emulated" == name || "self" == name) continue + scanApkDir(File(root, "apps"), out) + scanApkDir(File(root, "apk"), out) + } + } + Collections.sort( + out, + Comparator { a: File?, b: File? -> + b!!.getName().compareTo(a!!.getName(), ignoreCase = true) + }) + return out + } + + private fun scanApkDir(dir: File, out: MutableList) { + val files = dir.listFiles() + if (files == null) return + for (file in files) { + if (file.isDirectory()) scanApkDir(file, out) + else if (file.getName().lowercase().endsWith(".apk")) out.add(file) + } + } + + private fun installApk(apk: File) { + try { + val uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", apk) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(uri, "application/vnd.android.package-archive") + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + startActivity(intent) + } catch (e: Exception) { + toast("无法打开安装器:" + e.message) + } + } + + private fun scanMovieDir(dir: File, out: MutableList) { + val files = dir.listFiles() + if (files == null) return + for (file in files) { + if (file.isDirectory()) scanMovieDir(file, out) + else if (isMovie(file.getName())) out.add(file) + } + } + + private fun isMovie(name: String): Boolean { + val lower = name.lowercase() + return lower.endsWith(".mp4") || lower.endsWith(".mkv") || lower.endsWith(".avi") + || lower.endsWith(".mov") || lower.endsWith(".ts") || lower.endsWith(".flv") + } + + private fun playMovie(file: File) { + if (player == null) showHome() + try { + releaseVocalPlayer() + currentSong = null + clearLyrics() + player!!.setOnPreparedListener(OnPreparedListener { mp: MediaPlayer? -> + player!!.start() + status!!.setText("正在播放影片:" + file.getName()) + }) + player!!.setOnCompletionListener(OnCompletionListener { mp: MediaPlayer? -> + status!!.setText("影片播放完成:" + file.getName()) + if (autoNext && !orderQueue!!.isEmpty()) play(orderQueue.get(0)) + }) + player!!.setVideoURI(Uri.fromFile(file)) + } catch (e: Exception) { + toast("影片播放失败:" + e.message) + } + } + + private fun safeFileName(value: String?): String { + return if (value == null) "ktv" else value.replace("[\\\\/:*?\"<>|]".toRegex(), "_") + } + + private fun backupDir(): File { + return AppPaths.backupDir + } + + private fun backupConfig() { + try { + val dir = backupDir() + if (!dir.exists()) dir.mkdirs() + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(Date()) + copyFile(store.stateFile(), File(dir, "state-" + stamp + ".json")) + if (library.catalogFile().exists()) copyFile( + library.catalogFile(), + File(dir, "catalog-" + stamp + ".json") + ) + toast("配置已备份") + showBackupTools() + } catch (e: Exception) { + toast("备份失败:" + e.message) + } + } + + private fun restoreConfig() { + val dir = backupDir() + val files = dir.listFiles() + if (files == null) { + toast("没有备份文件") + return + } + var newestState: File? = null + var newestCatalog: File? = null + for (file in files) { + if (file.getName().startsWith("state-") && file.getName().endsWith(".json") + && (newestState == null || file.lastModified() > newestState.lastModified()) + ) newestState = file + if (file.getName().startsWith("catalog-") && file.getName().endsWith(".json") + && (newestCatalog == null || file.lastModified() > newestCatalog.lastModified()) + ) newestCatalog = file + } + val state = newestState + val catalog = newestCatalog + if (state == null && catalog == null) { + toast("没有可恢复的备份") + return + } + AlertDialog.Builder(this) + .setTitle("从备份恢复") + .setMessage("将恢复最近的 state/catalog 备份,应用内状态会重新加载。") + .setPositiveButton( + "恢复", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + try { + if (state != null) copyFile(state, store.stateFile()) + if (catalog != null) copyFile(catalog, library.catalogFile()) + store.load() + loadStateFromStore() + applyScreenBrightness() + refreshLibrary(Runnable { + toast("恢复完成") + showHome() + }) + } catch (e: Exception) { + toast("恢复失败:" + e.message) + } + }) + .setNegativeButton("取消", null) + .create().showForTv() + } + + @Throws(Exception::class) + private fun copyFile(source: File?, target: File) { + if (source == null || !source.exists()) return + val parent = target.getParentFile() + if (parent != null && !parent.exists()) parent.mkdirs() + BufferedInputStream(FileInputStream(source)).use { `in` -> + FileOutputStream(target, false).use { out -> + val buffer = ByteArray(64 * 1024) + var read: Int + while ((`in`.read(buffer).also { read = it }) > 0) out.write(buffer, 0, read) + } + } + } + + private fun applySystemMusicVolume() { + if (audioManager == null) return + val max = audioManager!!.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val value = max(0, min(max, (musicVolume * max) / 100)) + audioManager!!.setStreamVolume(AudioManager.STREAM_MUSIC, value, 0) + } + + private fun clamp(value: Int, min: Int, max: Int): Int { + return max(min, min(max, value)) + } + + private fun formatSize(bytes: kotlin.Long): String { + if (bytes > 1024L * 1024L * 1024L) return String.format( + Locale.ROOT, + "%.1f GB", + bytes / 1024f / 1024f / 1024f + ) + if (bytes > 1024L * 1024L) return String.format( + Locale.ROOT, + "%.1f MB", + bytes / 1024f / 1024f + ) + return bytes.toString() + " B" + } + + private fun populateSingerList(adapter: ArrayAdapter, group: String?) { + adapter.clear() + val names: MutableList = ArrayList() + for (song in library.allSongs()) { + if (!isVisibleSong(song) || !singerMatchesGroup(song, group)) continue + val name = singerName(song) + if (!names.contains(name)) names.add(name) + } + Collections.sort( + names, + Comparator { obj: String?, str: String? -> obj!!.compareTo(str!!, ignoreCase = true) }) + if ("热门歌手" == group) { + Collections.sort( + names, + Comparator { a: String?, b: String? -> + Integer.compare( + singerPlayCount(b), + singerPlayCount(a) + ) + }) + } + adapter.addAll(names) + adapter.notifyDataSetChanged() + visibleSongs.clear() + renderSongs() + header!!.setText("歌星 " + group + " " + names.size) + } + + private fun singerPlayCount(singer: String?): Int { + var count = 0 + for (song in library.allSongs()) { + if (isVisibleSong(song) && singerName(song) == singer) count += song.playCount + } + return count + } + + private fun singerMatchesGroup(song: Song, group: String?): Boolean { + if ("全部歌手" == group) return true + val text = (((if (song.singer == null) "" else song.singer) + " " + + (if (song.category == null) "" else song.category) + " " + + (if (song.language == null) "" else song.language))).lowercase() + if ("热门歌手" == group) return song.playCount > 0 || !song.remote + if ("大陆/内地" == group) return text.contains("大陆") || text.contains("内地") || text.contains( + "国语" + ) + if ("港台" == group) return text.contains("香港") || text.contains("台湾") || text.contains( + "港台" + ) || text.contains("粤语") || text.contains("闽南") + if ("日韩" == group) return text.contains("日本") || text.contains("韩国") || text.contains( + "日韩" + ) || text.contains("日语") || text.contains("韩语") + if ("欧美" == group) return text.contains("欧美") || text.contains("英语") || text.contains( + "英文" + ) || text.contains("外语") + if ("组合/乐队" == group) return text.contains("组合") || text.contains("乐队") || text.contains( + "band" + ) || text.contains("group") + return !singerMatchesGroup(song, "大陆/内地") && !singerMatchesGroup( + song, + "港台" + ) && !singerMatchesGroup(song, "日韩") && !singerMatchesGroup( + song, + "欧美" + ) && !singerMatchesGroup(song, "组合/乐队") + } + + private fun filterLanguage(language: String?) { + visibleSongs.clear() + for (song in library.allSongs()) { + val value = song.language?.takeIf(String::isNotEmpty) ?: "其他" + if (isVisibleSong(song) && ("全部" == language || value == language)) visibleSongs.add( + song + ) + } + Collections.sort( + visibleSongs, + Comparator.comparing(Function { song: Song? -> song!!.title!!.lowercase() }) + ) + renderSongs() + } + + private fun filterWordCount(group: String?) { + visibleSongs.clear() + for (song in library.allSongs()) { + val count = songWordCount(song.title) + if (isVisibleSong(song) && ("全部" == group || wordGroupMatches( + group, + count + )) + ) visibleSongs.add(song) + } + Collections.sort( + visibleSongs, + Comparator.comparing(Function { song: Song? -> song!!.title!!.lowercase() }) + ) + renderSongs() + } + + private fun wordGroupMatches(group: String?, count: Int): Boolean { + if ("一字" == group) return count == 1 + if ("二字" == group) return count == 2 + if ("三字" == group) return count == 3 + if ("四字" == group) return count == 4 + if ("五字" == group) return count == 5 + return "六字以上" == group && count >= 6 + } + + private fun songWordCount(title: String?): Int { + var count = 0 + val value = if (title == null) "" else title + for (i in 0 until value.length) { + val ch = value.get(i) + if (Character.isLetterOrDigit(ch) || (ch >= '\u4e00' && ch <= '\u9fa5')) count++ + } + return count + } + + private fun singerName(song: Song): String { + return song.singer?.takeIf(String::isNotEmpty) ?: "未知歌手" + } + + private fun isVisibleSong(song: Song): Boolean { + return !store.hiddenSongIds.contains(stableId(song)) + } + + private fun filterSongs(category: String?, query: String?) { + val q = if (query == null) "" else query.trim { it <= ' ' } + // 优先使用数据库搜索(64 万+歌曲) + if (library.muse.isAvailable() && !q.isEmpty()) { + browseMode = "search" + browseParam = q + browsePage = 0 + loadBrowsePage() + return + } + // 数据库不可用或空查询时,回退到本地文件扫描 + val ql = q.lowercase() + visibleSongs.clear() + for (song in library.allSongs()) { + val categoryOk = "全部" == category + || (song.category != null && song.category == category) + || ("本地" == category && !song.remote) + || ("网络" == category && song.remote) + val text = + (song.title + " " + song.singer + " " + song.category + " " + song.language + " " + song.pinyin).lowercase() + if (isVisibleSong(song) && categoryOk && (ql.isEmpty() || text.contains(ql))) visibleSongs.add( + song + ) + } + Collections.sort( + visibleSongs, + Comparator.comparing(Function { song: Song? -> song!!.title!!.lowercase() }) + ) + renderSongs() + if (pageInfo != null) pageInfo!!.setText("本地 " + visibleSongs.size + " 首") + } + + /** + * 加载当前分页浏览模式的歌曲列表。 + * 根据 browseMode 和 browseParam 从数据库查询对应页的数据。 + */ + /** + * 安全解析整数。 + * + * @param value 字符串 + * @param defaultValue 默认值 + * @return 解析结果 + */ + private fun parseIntSafe(value: String?, defaultValue: Int): Int { + if (value == null || value.isEmpty()) return defaultValue + try { + return value.trim { it <= ' ' }.toInt() + } catch (e: Exception) { + return defaultValue + } + } + + /** + * 公共点歌方法:将歌曲加入已点队列并开始播放。 + * + * + * 供 [SongSearchFragment] 等外部组件调用,实现点歌功能。 + * + * @param song 要点播的歌曲 + */ + fun orderSong(song: Song) { + handleSong(song) + } + + val muse: MuseDatabase + /** + * 获取 Muse 曲库数据库访问层(供 Fragment 查询歌曲/歌手/排行)。 + * + * @return 全局 MuseDatabase 实例 + */ + get() = library.muse + + val playbackPosition: Int + get() { + try { + return player?.currentPosition ?: 0 + } catch (ignored: Exception) { + return 0 + } + } + + fun resumeMainPlaybackAt(positionMs: Int) { + try { + if (player != null && currentSong != null) { + player!!.seekTo(max(0, positionMs)) + player!!.start() + startVocalIfReady() + updateBottomBar(currentSong, true) + } + } catch (ignored: Exception) { + } + } + + /** + * 播放指定歌曲(供已点列表点击播放)。 + * + * @param song 要播放的歌曲 + */ + fun playSong(song: Song?) { + play(song) + } + + /** + * 切换到下一首歌曲(供已点列表切歌操作)。 + */ + fun playNextSong() { + playNext() + } + + private fun handleSong(song: Song) { + addToQueue(song) + if (currentPage == "已点" || currentPage == "首页") renderQueue() + } + + private fun download(song: Song) { + val mainUrl = if (song.downloadUrl == null || song.downloadUrl!!.trim { it <= ' ' } + .isEmpty()) song.videoUrl else song.downloadUrl + if (mainUrl == null || mainUrl.trim { it <= ' ' }.isEmpty()) { + toast("该条目没有下载地址") + return + } + val task = DownloadTask(song) + downloads.add(task) + busy(true, "正在下载:" + song.title) + io.execute(Runnable { + val target = library.targetFor(song) + try { + downloadToFile(mainUrl, target, task, 0, 75) + val originalFile = sidecarFile(target, ".original", song.originalUrl) + val accompanyFile = sidecarFile(target, ".accompany", song.accompanyUrl) + val lyricFile = sidecarFile(target, "", song.lyricUrl) + if (hasUrl(song.originalUrl)) downloadToFile( + song.originalUrl, + originalFile, + task, + 75, + 85 + ) + if (hasUrl(song.accompanyUrl)) downloadToFile( + song.accompanyUrl, + accompanyFile, + task, + 85, + 95 + ) + if (hasUrl(song.lyricUrl)) downloadToFile(song.lyricUrl, lyricFile, task, 95, 99) + if (task.cancelled) throw InterruptedException("cancelled") + task.state = "完成" + task.progress = 100 + val local = local(target.getAbsolutePath(), target.getName()) + local.originalPath = + if (originalFile.exists()) originalFile.getAbsolutePath() else "" + local.accompanyPath = + if (accompanyFile.exists()) accompanyFile.getAbsolutePath() else "" + local.lyricPath = if (lyricFile.exists()) lyricFile.getAbsolutePath() else "" + main.post(Runnable { + orderQueue!!.add(local) + saveState() + refreshLibrary(null) + busy(false, "下载完成并已加入已点:" + local.title) + if (currentTabIndex == 6) loadDownloadedList() + }) + } catch (e: InterruptedException) { + task.state = "已取消" + target.delete() + cleanupSidecars(target) + main.post(Runnable { + busy(false, "下载已取消:" + song.title) + if (currentTabIndex == 6) loadDownloadedList() + }) + } catch (e: Exception) { + task.state = "失败:" + e.message + main.post(Runnable { + busy(false, "下载失败:" + e.message) + if (currentTabIndex == 6) loadDownloadedList() + }) + } + }) + } + + @Throws(Exception::class) + private fun downloadToFile( + url: String?, + target: File?, + task: DownloadTask, + base: Int, + span: Int + ) { + val conn = URL(url).openConnection() as HttpURLConnection + conn.setConnectTimeout(10000) + conn.setReadTimeout(30000) + val code = conn.getResponseCode() + check(!(code < 200 || code >= 300)) { "HTTP " + code } + val total = conn.getContentLength() + var done = 0 + task.state = "下载中" + try { + BufferedInputStream(conn.getInputStream()).use { `in` -> + FileOutputStream(target).use { out -> + val buffer = ByteArray(64 * 1024) + var read: Int + while ((`in`.read(buffer).also { read = it }) > 0) { + if (task.cancelled) throw InterruptedException("cancelled") + out.write(buffer, 0, read) + done += read + if (total > 0) task.progress = min(99, base + (done * span) / total) + } + } + } + } finally { + conn.disconnect() + } + } + + private fun hasUrl(value: String?): Boolean { + return value != null && !value.trim { it <= ' ' }.isEmpty() + } + + private fun sidecarFile(media: File, suffix: String?, url: String?): File { + var ext = ".mp3" + if (url != null) { + val dot = url.lastIndexOf('.') + val q = url.indexOf('?', dot) + if (dot >= 0) ext = url.substring(dot, if (q > dot) q else url.length) + if (ext.length > 8 || ext.contains("/")) ext = ".mp3" + } + val path = media.getAbsolutePath() + val dot = path.lastIndexOf('.') + val base = if (dot > 0) path.substring(0, dot) else path + return File(base + suffix + ext) + } + + private fun cleanupSidecars(media: File) { + val dir = media.getParentFile() + if (dir == null) return + val path = media.getAbsolutePath() + val dot = path.lastIndexOf('.') + val base = if (dot > 0) path.substring(0, dot) else path + val files = dir.listFiles() + if (files == null) return + for (file in files) { + val value = file.getAbsolutePath() + if (value.startsWith(base + ".original") || value.startsWith(base + ".accompany") || value.startsWith( + base + ".lrc" + ) + ) { + file.delete() + } + } + } + + private fun cancelAllDownloads() { + for (task in downloads) { + if ("完成" != task.state) { + task.cancelled = true + task.state = "正在取消" + } + } + showDownloads() + } + + private fun play(song: Song?) { + if (player == null) player = videoBackground + if (song == null) return + try { + // 远程日志: 点歌使用的源(本地文件 / 在线源)。 + MiliLog.info( + "PLAY id=${extractTid(song)} title=${song.title} src=" + + (if (song.hasLocalFile()) "local" else "online"), + ) + // 取消仍在后台扫描的“已点播完后随机本地歌曲”请求,避免抢占新点歌曲。 + randomLocalRequestSerial++ + playbackGeneration++ + songRetryCount = 0 // 重置重试计数 + playWhenPrepared = true + hidePlaybackNoticeNow() + releaseVocalPlayer() + currentSong = song + loadLyrics(song) + persistRuntimeState() + + if (playerController != null) { + playerController!!.updateSongInfo(song) + playerController!!.setVocalMode(originalVocal) + } + + updateBottomBar(song, true) + scheduleAutoFullscreen() + + if (song.hasLocalFile()) { + playLocalFile(song) + } else { + playbackPreparing = true + // 统一使用支持断点续传和下载地址重试的下载管理器。 + if (isDownloading(song)) { + showDownloadProgressFor(song) + return + } + toast("加载中: " + song.title + "...") + showDownloadProgressFor(song) + startDownloadAndPlay(song) + } + } catch (e: Exception) { + toast("播放失败:" + e.message) + } + } + + private fun playLocalFile(song: Song, startPositionMs: Int = 0) { + try { + playbackPreparing = true + val localPath = song.path + if (localPath.isNullOrEmpty()) { + playbackPreparing = false + startDownloadAndPlay(song) + return + } + val f = File(localPath) + val inspection = SongFileValidator.inspect(f, SongFileValidator.requiresTransportStream(f)) + if (!inspection.valid) { + Log.w(TAG, "Invalid local file: ${song.path}, ${inspection.reason}") + if (f.exists()) runCatching { f.delete() } + song.path = null + toast("本地文件丢失,重新下载...") + startDownloadAndPlay(song) + return + } + Log.i( + TAG, + "playLocalFile song=${stableId(song)} generation=$playbackGeneration " + + "startPositionMs=$startPositionMs path=${f.absolutePath} size=${f.length()}", + ) + // 自动清理以文件时间作为最近使用时间;每次实际播放都刷新,避免常唱歌曲被误删。 + runCatching { f.setLastModified(System.currentTimeMillis()) } + player!!.stopPlayback() + setupPlayerListeners(song, startPositionMs, playbackGeneration) + player!!.setVideoURI(Uri.fromFile(f)) + if (playWhenPrepared) player!!.start() + } catch (e: Exception) { + playbackPreparing = false + Log.e(TAG, "playLocalFile error: " + e.message, e) + toast("播放失败") + } + } + + /** + * 完整下载后播放。VideoView不支持流式TS,必须下载完再播。 + * 使用256KB大缓冲区加速下载,实时显示进度。 + */ + private fun streamDownloadAndPlay(song: Song) { + io.execute(Runnable { + var tmpFile: File? = null + try { + var url = song.downloadUrl + if (url == null || url.isEmpty()) { + url = getSongDownloadUrl(extractTid(song)) + if (url != null) song.downloadUrl = url + } + if (url == null || url.isEmpty()) { + MiliLog.error( + "点歌编号=${song?.let(::extractTid)} 标题=${song?.title} 请求源=w.w345.my 状态=失败 详情=无法获取播放地址", + ) + main.post(Runnable { + hideDownloadProgress() + toast("无法获取播放地址") + }) + return@Runnable + } + + val finalFile = getLocalFile(song) + finalFile.getParentFile().mkdirs() + tmpFile = File(finalFile.getParent(), finalFile.getName() + ".tmp") + + val conn = URL(url).openConnection() as HttpURLConnection + conn.setConnectTimeout(10000) + conn.setReadTimeout(30000) + conn.setRequestProperty("User-Agent", "ThunderSDK/4.1.3") + conn.connect() + + val totalSize = conn.getContentLength().toLong() + val `in`: InputStream = BufferedInputStream(conn.getInputStream(), 262144) + val out = FileOutputStream(tmpFile) + + val buf = ByteArray(262144) // 256KB buffer for speed + var downloaded: kotlin.Long = 0 + var lastPct = 0 + var lastTime = System.currentTimeMillis() + var lastBytes: kotlin.Long = 0 + + while (true) { + val read = `in`.read(buf) + if (read < 0) break + out.write(buf, 0, read) + downloaded += read.toLong() + + // 每秒更新一次进度和速度 + val now = System.currentTimeMillis() + if (now - lastTime > 1000) { + val pct = if (totalSize > 0) (downloaded * 100 / totalSize).toInt() else 0 + val speed = downloaded - lastBytes + lastPct = pct + lastTime = now + lastBytes = downloaded + val p = pct + val speedStr = formatSize(speed) + "/s" + main.post(Runnable { + updateDownloadProgress(song, p) + if (textDownloadSpeed != null) textDownloadSpeed!!.setText(speedStr) + }) + } + } + out.close() + `in`.close() + conn.disconnect() + + // 移到正式位置 + tmpFile.renameTo(finalFile) + song.path = finalFile.getAbsolutePath() + + main.post(Runnable { + hideDownloadProgress() + if (currentSong != null && currentSong!!.equals(song)) { + playLocalFile(song) + } + toast(song.title + " 就绪") + }) + } catch (e: Exception) { + Log.e(TAG, "download error: " + e.message) + if (tmpFile != null) tmpFile.delete() + MiliLog.error("DL-ERR id=${song?.let(::extractTid)} title=${song?.title} ${e.message}") + main.post(Runnable { + hideDownloadProgress() + toast("下载失败: " + e.message) + }) + } + }) + } + + /** + * 下载歌曲并在完成后自动播放 (旧版,备用)。 + */ + private fun startDownloadAndPlay(song: Song) { + downloadSong(song, object : DownloadCallback { + override fun onDownloadStart(s: Song) { + main.post(Runnable { + updateDownloadProgress(s, 0) + }) + } + + override fun onDownloadProgress(s: Song, progress: Int) { + main.post(Runnable { updateDownloadProgress(s, progress) }) + } + + override fun onDownloadReadyToPlay(s: Song) {} + override fun onDownloadComplete(s: Song, localPath: String) { + main.post { hideDownloadProgress() } + } + + override fun onDownloadFailed(s: Song, error: String) { + MiliLog.error("DL-FAIL id=${extractTid(s)} title=${s.title} $error") + main.post(Runnable { + hideDownloadProgress() + toast("下载失败: " + error) + }) + } + }) + } + + private fun showDownloadProgressFor(song: Song) { + if (downloadProgressContainer != null) downloadProgressContainer!!.setVisibility(View.VISIBLE) + if (textDownloadStatus != null) textDownloadStatus!!.setText("下载中: " + song.title) + if (textDownloadSpeed != null) textDownloadSpeed!!.setText("") + if (downloadProgressBar != null) downloadProgressBar!!.setProgress(0) + } + + private fun updateDownloadProgress(song: Song?, progress: Int) { + if (downloadProgressContainer != null) downloadProgressContainer!!.setVisibility(View.VISIBLE) + if (textDownloadStatus != null) textDownloadStatus!!.setText("下载: " + (if (song != null) song.title else "") + " " + progress + "%") + if (textDownloadSpeed != null) textDownloadSpeed!!.setText(progress.toString() + "%") + if (downloadProgressBar != null) downloadProgressBar!!.setProgress(progress) + } + + private fun hideDownloadProgress() { + if (downloadProgressContainer != null) downloadProgressContainer!!.setVisibility(View.GONE) + } + + private fun setupPlayerListeners(song: Song?, startPositionMs: Int = 0, generation: Long = playbackGeneration) { + player!!.setOnPreparedListener(OnPreparedListener { mp: MediaPlayer? -> + if (generation != playbackGeneration || currentSong?.equals(song) != true) return@OnPreparedListener + Log.i(TAG, "onPrepared song=${song?.let(::stableId)} generation=$generation play=$playWhenPrepared") + playbackPreparing = false + currentMediaPlayer = mp + applyPlaybackMode() + if (startPositionMs > 0) player!!.seekTo(startPositionMs) + prepareExternalVocal(song, startPositionMs) + if (playWhenPrepared) { + player!!.start() + startVocalIfReady() + } + updateBottomBar(song, playWhenPrepared) + if (startPositionMs <= 0) showSongIntro(song) + persistRuntimeState() + }) + player!!.setOnCompletionListener(OnCompletionListener { mp: MediaPlayer? -> + if (generation != playbackGeneration || currentSong?.equals(song) != true) return@OnCompletionListener + if (System.currentTimeMillis() < suppressCompletionUntil) return@OnCompletionListener + // 模拟器/部分设备会连续触发多次 onCompletion, 加 800ms 去抖, 防止跳歌或同一首被重复处理。 + val nowMs = System.currentTimeMillis() + if (nowMs - lastAutoAdvanceAtMs < 800L) return@OnCompletionListener + lastAutoAdvanceAtMs = nowMs + main.post(Runnable { playNext(true) }) + }) + player!!.setOnErrorListener(MediaPlayer.OnErrorListener { mp: MediaPlayer?, what: Int, extra: Int -> + if (generation != playbackGeneration || currentSong?.equals(song) != true) return@OnErrorListener true + playbackPreparing = false + Log.e(TAG, "MediaPlayer error what=" + what + " extra=" + extra) + // 远程日志: 播放网络/解码错误实时上报。 + MiliLog.error( + "PLAY-ERR id=${song?.let(::extractTid)} title=${song?.title} what=$what extra=$extra" + + (if (song?.hasLocalFile() == true) " local" else " stream"), + ) + if (songRetryCount < 2 && song != null && song.hasLocalFile()) { + songRetryCount++ + val retry = songRetryCount + main.postDelayed(Runnable { + toast("播放重试 " + retry + "/2") + playLocalFile(song) + }, 700L * retry) + } else { + songRetryCount = 0 + main.post(Runnable { this.playNext() }) + } + true + }) + } + + private fun switchToLocalPlayback(song: Song?) { + if (currentSong == null || !currentSong!!.equals(song)) return + if (!song!!.hasLocalFile()) return + var pos = 0 + try { + pos = player!!.currentPosition + } catch (ignored: Exception) { + } + player!!.stopPlayback() + playLocalFile(song, pos) + } + + /** + * 更新底部控制栏显示 + */ + private fun updateBottomBar(song: Song?, playing: Boolean) { + if (playing && playbackModeNotice?.text?.contains("暂停") == true) hidePlaybackNoticeNow() + if (playerSongTitle != null) playerSongTitle!!.setText(if (song != null) song.title else "未播放") + if (playerSongSinger != null) playerSongSinger!!.setText(if (song != null) (if (song.singer != null) song.singer else "") else "") + if (textVocalMode != null) textVocalMode.setText(vocalActionText()) + if (textPlayPause != null) textPlayPause.setText(if (playing) "暂停" else "播放") + if (btnTopPause != null) btnTopPause!!.setText(if (playing) "暂停" else "播放") + if (btnTopVocal != null) btnTopVocal!!.setText(vocalActionText()) + setTopControlIcon(btnTopPause, if (playing) R.drawable.ott_ic_ctrl_pause else R.drawable.ott_ic_ctrl_play) + setTopControlIcon( + btnTopVocal, + vocalActionIcon(), + ) + fullScreenPauseText?.text = if (playing) "暂停" else "播放" + fullScreenPauseIcon?.setImageResource(if (playing) R.drawable.ott_ic_ctrl_pause else R.drawable.ott_ic_ctrl_play) + fullScreenVocalText?.text = vocalActionText() + fullScreenVocalIcon?.setImageResource(vocalActionIcon()) + playerController?.updatePlayState(playing) + playerController?.setVocalMode(originalVocal) + syncPlayerInfo() + updateOrderBadge() + } + + /** + * 从 CDN 下载歌曲文件。 + * 使用 global_confs 表中的 cdn_path 作为基础 URL。 + * + * @param song 要下载的歌曲 + */ + private fun downloadFromCdn(song: Song) { + if (song.filename == null || song.filename!!.isEmpty()) { + toast("无法下载:缺少文件名") + return + } + val cdnBase = "https://pub.mcdn.cherryonline.cn/" + val url = cdnBase + "cloud-song/" + song.filename + val task = DownloadTask(song) + downloads.add(task) + busy(true, "正在从云端下载:" + song.title) + io.execute(Runnable { + val target = + File(MuseDatabase.VIDEO_ROOT + "/" + MuseDatabase.CLOUD_SONG_DIR, song.filename) + try { + File(target.getParent()).mkdirs() + downloadToFile(url, target, task, 0, 100) + task.state = "完成" + task.progress = 100 + song.path = target.getAbsolutePath() + main.post(Runnable { + busy(false, "下载完成") + if (currentTabIndex == 6) loadDownloadedList() + if (currentSong?.equals(song) == true && playbackPreparing) { + switchToLocalPlayback(song) + } + }) + } catch (e: Exception) { + task.state = "失败:" + e.message + main.post(Runnable { + busy(false, "下载失败:" + e.message) + if (currentTabIndex == 6) loadDownloadedList() + }) + } + }) + } + + /** + * 使用 SongOkDownloadManager 下载歌曲到本地。 + */ + private fun downloadSong(song: Song, extraCallback: DownloadCallback? = null, storageChecked: Boolean = false) { + if (SongOkDownloadManager.isDownloading(song)) { + if (extraCallback != null) extraCallback.onDownloadStart(song) + return + } + if (!storageChecked && autoDeleteSongs && isBelowReservedStorage()) { + io.execute { + purgeColdDownloadedFiles() + main.post { downloadSong(song, extraCallback, storageChecked = true) } + } + return + } + if (::stateDatabase.isInitialized) runCatching { + stateIo.execute { stateDatabase.updateDownload(song, "queued", 0) } + } + download(song, object : DownloadCallback { + override fun onDownloadStart(song: Song) { + if (extraCallback != null) extraCallback.onDownloadStart(song) + if (::stateDatabase.isInitialized) runCatching { + stateDatabase.updateDownload(song, "downloading", 0) + } + main.post(Runnable { + val task = downloads.firstOrNull { stableId(it.song) == stableId(song) } + ?: DownloadTask(song).also { downloads.add(it) } + task.state = "下载中" + task.progress = 0 + refreshSongAdapter() + if (currentTabIndex == 6) loadDownloadedList() + if (currentSong?.equals(song) == true && playbackPreparing) updateDownloadProgress(song, 0) + }) + } + + override fun onDownloadProgress(song: Song, progress: Int) { + if (extraCallback != null) extraCallback.onDownloadProgress(song, progress) + if (::stateDatabase.isInitialized) runCatching { + stateDatabase.updateDownload(song, "downloading", progress) + } + main.post(Runnable { + for (task in downloads) { + if (task.song === song || task.song.id != null && task.song.id == song.id) { + task.progress = progress + break + } + } + if (currentTabIndex == 6) loadDownloadedList() + if (currentSong?.equals(song) == true && playbackPreparing) updateDownloadProgress(song, progress) + }) + } + + override fun onDownloadReadyToPlay(song: Song) { + if (extraCallback != null) extraCallback.onDownloadReadyToPlay(song) + // 后台预下载不能抢占当前播放;当前点播在完整文件落盘后切换。 + } + + override fun onDownloadComplete(song: Song, localPath: String) { + if (extraCallback != null) extraCallback.onDownloadComplete(song, localPath) + if (::stateDatabase.isInitialized) runCatching { + stateDatabase.removeDownload(song) + } + main.post(Runnable { + downloads.removeAll { stableId(it.song) == stableId(song) } + song.path = localPath + refreshSongAdapter() + val shouldAddToQueue = pendingQueueSongIds.remove(stableId(song)) + Log.i( + TAG, + "downloadComplete song=${stableId(song)} pendingQueue=$shouldAddToQueue " + + "current=${currentSong?.let(::stableId)} preparing=$playbackPreparing path=$localPath", + ) + if (shouldAddToQueue) addReadySongToQueue(song) + if (currentTabIndex == 6) loadDownloadedList() + if (currentSong?.equals(song) == true && playbackPreparing) hideDownloadProgress() + if (!shouldAddToQueue && currentSong?.equals(song) == true && playbackPreparing) { + switchToLocalPlayback(song) + } + }) + enforceStorageReserve() + } + + override fun onDownloadFailed(song: Song, error: String) { + if (extraCallback != null) extraCallback.onDownloadFailed(song, error) + if (::stateDatabase.isInitialized) runCatching { + stateDatabase.updateDownload(song, "failed", 0, error = error) + } + main.post(Runnable { + val wasPendingQueue = pendingQueueSongIds.remove(stableId(song)) + if (currentSong != null && currentSong!!.equals(song)) playbackPreparing = false + for (task in downloads) { + if (task.song === song || task.song.id != null && task.song.id == song.id) { + task.state = "失败:" + error + break + } + } + refreshSongAdapter() + if (currentTabIndex == 6) loadDownloadedList() + if (currentSong?.equals(song) == true) hideDownloadProgress() + if (wasPendingQueue) toast("下载失败,未加入已点:${song.title}") + }) + } + }) + } + + /** + * 检查指定歌曲是否正在下载中。 + * + * + * 使用 SongOkDownloadManager 检查真实的下载状态。 + * + * @param song 要检查的歌曲 + * @return true 表示正在下载 + */ + /** 从 Song 中提取云端歌曲 TID (filename 格式: "7678785.ts" / "7586669.ls" → "7678785" / "7586669") */ + private fun extractTid(song: Song): String? { + if (song.filename != null) { + // 去掉 .ts 或 .ls 扩展名 + if (song.filename!!.endsWith(".ts")) return song.filename!!.substring( + 0, + song.filename!!.length - 3 + ) + if (song.filename!!.endsWith(".ls")) return song.filename!!.substring( + 0, + song.filename!!.length - 3 + ) + return song.filename + } + return song.id + } + + private fun isDownloading(song: Song): Boolean { + if (SongOkDownloadManager.isDownloading(song)) { + return true + } + for (task in downloads) { + val sameSong = task.song === song || stableId(task.song) == stableId(song) + val active = task.state == "等待" || task.state == "等待下载" || + task.state == "下载中" || task.state == "正在取消" + if (sameSong && active) return true + } + return false + } + + private fun playNext(fromCompletion: Boolean = false) { + if (advancingPlayback) return + advancingPlayback = true + try { + advancePlayback(fromCompletion) + } finally { + advancingPlayback = false + } + } + + private fun advancePlayback(fromCompletion: Boolean) { + // 单曲循环只影响自然播完;用户主动切歌始终进入下一首。 + if (fromCompletion && loopOne && player != null && currentSong != null) { + playWhenPrepared = true + player!!.seekTo(0) + prepareExternalVocal(currentSong, 0) + player!!.start() + startVocalIfReady() + return + } + // 队列为空(最后一首已播完/随机续播中未点歌) + if (orderQueue!!.isEmpty()) { + if (fromCompletion && autoNext) { + playRandomLocalSong() + return + } + // 用户主动切歌/停止:取消挂起的随机续播,进入空闲态。 + randomLocalRequestSerial++ + playbackPreparing = false + playWhenPrepared = false + releaseVocalPlayer() + currentSong = null + clearLyrics() + updateBottomBar(null, false) + if (status != null) status!!.setText("播放完成,请点歌") + return + } + // 移除当前歌曲进已唱列表。按稳定 ID 匹配,避免对象实例不同导致移不掉而重复播放同一首。 + val current = currentSong + if (current != null) { + sangHistory.add(0, current) + recordScore(current) + removeFromQueueByStableId(current) + } + saveState() + renderQueue() + if ((!fromCompletion || autoNext) && !orderQueue.isEmpty()) { + play(orderQueue!![0]) + } else if (fromCompletion && autoNext && orderQueue.isEmpty()) { + // 点歌清单播放完毕 → 自动续播本地已下载歌曲 + playRandomLocalSong() + } else { + // 显式切歌但队列已空:同样取消挂起的随机续播,避免用户停歌后又被随机歌打断。 + randomLocalRequestSerial++ + playbackPreparing = false + playWhenPrepared = false + releaseVocalPlayer() + currentSong = null + clearLyrics() + updateBottomBar(null, false) + if (status != null) status!!.setText("播放完成" + (if (orderQueue.isEmpty()) "" else ",点击播放继续")) + } + } + + /** 按稳定 ID 从已点队列移除歌曲(等价于移除“当前播放”,但不依赖对象实例相等)。 */ + private fun removeFromQueueByStableId(song: Song) { + val id = stableId(song) + val index = orderQueue!!.indexOfFirst { stableId(it) == id } + if (index >= 0) orderQueue!!.removeAt(index) + } + + /** + * 已点队列自然播放完毕后,从本机所有有效歌曲中随机续播。 + * 随机歌曲不写入已点列表;用户此时点歌会立即取消本次随机请求并优先播放已点歌曲。 + */ + private fun playRandomLocalSong() { + val previousSongId = currentSong?.let(::stableId) ?: lastRandomLocalSongId + val requestSerial = ++randomLocalRequestSerial + playbackPreparing = true + playWhenPrepared = true + releaseVocalPlayer() + currentSong = null + clearLyrics() + updateBottomBar(null, false) + status?.text = "已点播放完成,正在随机本地歌曲..." + persistRuntimeState() + + io.execute { + val localSongs = localSongInventory() + library.scanLocal().forEach { song -> + val file = song.path?.let(::File) ?: return@forEach + if (SongFileValidator.inspect( + file, + SongFileValidator.requiresTransportStream(file), + ).valid + ) { + localSongs.putIfAbsent(stableId(song), song) + } + } + val allLocalSongs = localSongs.values.toList() + val alternatives = allLocalSongs.filter { stableId(it) != previousSongId } + val selected = (if (alternatives.isNotEmpty()) alternatives else allLocalSongs).randomOrNull() + main.post { + if (requestSerial != randomLocalRequestSerial || orderQueue!!.isNotEmpty() || currentSong != null) { + return@post + } + if (selected != null) { + lastRandomLocalSongId = stableId(selected) + status?.text = "随机播放:${selected.title}" + play(selected) + } else { + playbackPreparing = false + playWhenPrepared = false + updateBottomBar(null, false) + status?.text = "播放完成,本地暂无可播放歌曲" + persistRuntimeState() + } + } + } + } + + private fun togglePlay() { + if (player == null) return + val targetPlaying = !playWhenPrepared + playWhenPrepared = targetPlaying + if (playbackPreparing) { + updateBottomBar(currentSong, targetPlaying) + if (targetPlaying) hidePlaybackNoticeNow() else showPlaybackModeNotice("暂停", true) + scheduleAutoFullscreen() + persistRuntimeState() + return + } + if (!targetPlaying) { + player!!.pause() + pauseVocalIfReady() + if (textPlayPause != null) textPlayPause.setText("播放") + showPlaybackModeNotice("暂停", true) + } else { + player!!.start() + startVocalIfReady() + if (textPlayPause != null) textPlayPause.setText("暂停") + hidePlaybackNoticeNow() + } + updateBottomBar(currentSong, targetPlaying) + scheduleAutoFullscreen() + persistRuntimeState() + } + + private fun replay() { + if (player == null) return + playWhenPrepared = true + suppressCompletionUntil = System.currentTimeMillis() + 1500L + player!!.seekTo(0) + updateLyricView() + prepareExternalVocal(currentSong, 0) + player!!.start() + startVocalIfReady() + hidePlaybackNoticeNow() + updateBottomBar(currentSong, true) + showSongIntro(currentSong) + persistRuntimeState() + } + + private fun applyPlaybackMode() { + val videoPlayer = player ?: return + videoPlayer.setTone(tone) + val external = prepareExternalVocal( + currentSong, + player?.currentPosition ?: 0 + ) + val embedded = !external && "自动" == vocalChannelMode && + prepareEmbeddedDualTrackVocal(currentSong, player?.currentPosition ?: 0) + var trackSelected = false + var channelApplied = false + if (!external && !embedded && "自动" == vocalChannelMode) { + trackSelected = applySelectedAudioTrack() + if (!trackSelected && currentSong != null && currentSong!!.accomp > 0) { + channelApplied = applyChannelByAccomp() + } + } + val alternateTrackActive = embedded && originalVocal != embeddedMainTrackOriginal + val volume = if (external || alternateTrackActive || "静音练唱" == singMode) { + 0f + } else { + max(0f, min(1f, musicVolume / 100f)) + } + try { + when { + external || "静音练唱" == singMode -> videoPlayer.setPlaybackVolume(0f, 0f) + embedded -> videoPlayer.setPlaybackVolume(volume, volume) + trackSelected -> videoPlayer.selectAudioChannel(IjkMediaPlayer.AUDIO_CHANNEL_STEREO, volume) + "自动" != vocalChannelMode -> { + videoPlayer.selectAudioChannel(selectedChannelForMode(), volume) + status?.text = "声道模式:$vocalChannelMode / ${if (originalVocal) "原唱" else "伴唱"}" + } + channelApplied -> Unit + else -> videoPlayer.selectAudioChannel(IjkMediaPlayer.AUDIO_CHANNEL_STEREO, volume) + } + } catch (ignored: Exception) { + } + applyVocalVolume() + if (embedded && vocalPlayerPrepared) { + val mainPosition = videoPlayer.currentPosition + val alternatePosition = embeddedVocalEngine?.currentPosition ?: mainPosition + Log.i( + TAG, + "Embedded seamless switch original=$originalVocal main=$mainPosition " + + "alternate=$alternatePosition drift=${alternatePosition - mainPosition}", + ) + } + } + + /** + * 基于 songs.accomp 字段自动切换原唱/伴奏声道。 + * + * + * accomp=1:左声道=伴奏,右声道=原唱 + * accomp=2:左声道=原唱,右声道=伴奏 + * + * + * 当 originalVocal=true 时,播放原唱声道,静音伴奏声道; + * 当 originalVocal=false 时,播放伴奏声道,静音原唱声道。 + * + * @return true 表示成功应用声道切换 + */ + private fun applyChannelByAccomp(): Boolean { + val videoPlayer = player ?: return false + if (currentSong == null || currentSong!!.accomp <= 0) return false + try { + val volume = max(0f, min(1f, musicVolume / 100f)) + val channel = if (currentSong!!.accomp == 1) { + if (originalVocal) IjkMediaPlayer.AUDIO_CHANNEL_RIGHT else IjkMediaPlayer.AUDIO_CHANNEL_LEFT + } else { + if (originalVocal) IjkMediaPlayer.AUDIO_CHANNEL_LEFT else IjkMediaPlayer.AUDIO_CHANNEL_RIGHT + } + videoPlayer.selectAudioChannel(channel, volume) + status!!.setText((if (originalVocal) "原唱" else "伴唱") + "(声道" + currentSong!!.accomp + ")") + return true + } catch (e: Exception) { + return false + } + } + + private fun applySelectedAudioTrack(): Boolean { + val selected = player?.selectAudioTrack(originalVocal) == true + if (selected) status?.text = if (originalVocal) "已选择原唱音轨" else "已选择伴唱音轨" + return selected + } + + private fun selectedChannelForMode(): Int = when (vocalChannelMode) { + "左伴右原" -> if (originalVocal) IjkMediaPlayer.AUDIO_CHANNEL_RIGHT else IjkMediaPlayer.AUDIO_CHANNEL_LEFT + "右伴左原" -> if (originalVocal) IjkMediaPlayer.AUDIO_CHANNEL_LEFT else IjkMediaPlayer.AUDIO_CHANNEL_RIGHT + else -> IjkMediaPlayer.AUDIO_CHANNEL_STEREO + } + + private fun channelVolumes(volume: Float): FloatArray { + if ("左伴右原" == vocalChannelMode) { + return if (originalVocal) floatArrayOf(0f, volume) else floatArrayOf(volume, 0f) + } + if ("右伴左原" == vocalChannelMode) { + return if (originalVocal) floatArrayOf(volume, 0f) else floatArrayOf(0f, volume) + } + return floatArrayOf(volume, volume) + } + + private fun prepareExternalVocal(song: Song?, positionMs: Int): Boolean { + val path = selectedVocalPath(song) + if (!hasUrl(path)) { + if (embeddedDualTrackMode && embeddedVocalEngine != null) { + pendingVocalPosition = adjustedAudioPosition(positionMs) + if (vocalPlayerPrepared) runCatching { + val drift = kotlin.math.abs(embeddedVocalEngine!!.currentPosition - pendingVocalPosition) + if (drift > 300 && originalVocal == embeddedMainTrackOriginal) { + embeddedVocalEngine!!.seekTo(pendingVocalPosition) + } + } + return false + } + releaseVocalPlayer() + return false + } + if (path == currentVocalPath && vocalPlayer != null) { + pendingVocalPosition = adjustedAudioPosition(positionMs) + if (vocalPlayerPrepared) { + runCatching { + vocalPlayer!!.seekTo(pendingVocalPosition) + applyVocalVolume() + if (playWhenPrepared) startVocalIfReady() else pauseVocalIfReady() + } + } + return true + } + releaseVocalPlayer() + try { + embeddedDualTrackMode = false + val candidate = MediaPlayer() + vocalPlayer = candidate + vocalPlayerPrepared = false + pendingVocalPosition = adjustedAudioPosition(positionMs) + currentVocalPath = path + candidate.setDataSource(path) + candidate.setAudioStreamType(AudioManager.STREAM_MUSIC) + candidate.setOnCompletionListener(OnCompletionListener { _: MediaPlayer? -> }) + candidate.setOnPreparedListener { preparedPlayer -> + if (vocalPlayer !== preparedPlayer || currentVocalPath != path) { + runCatching { preparedPlayer.release() } + return@setOnPreparedListener + } + vocalPlayerPrepared = true + if (pendingVocalPosition > 0) runCatching { preparedPlayer.seekTo(pendingVocalPosition) } + applyVocalVolume() + if (playWhenPrepared) startVocalIfReady() else pauseVocalIfReady() + } + candidate.setOnErrorListener { failedPlayer, _, _ -> + if (vocalPlayer === failedPlayer) releaseVocalPlayer() + true + } + candidate.prepareAsync() + return true + } catch (e: Exception) { + releaseVocalPlayer() + toast("外置原伴唱加载失败:" + e.message) + return false + } + } + + /** + * 双音轨 TS 使用一个常驻的静音副播放器预解码另一条音轨。切换时只交叉调整两个 + * 播放器的音量,不再对主 IJKPlayer 调用 selectTrack,避免 AudioTrack 被销毁重建。 + */ + private fun prepareEmbeddedDualTrackVocal(song: Song?, positionMs: Int): Boolean { + val videoPlayer = player ?: return false + val path = song?.path?.takeIf { it.isNotBlank() } ?: return false + if (videoPlayer.audioTrackCount() < 2 || !File(path).isFile) { + if (embeddedDualTrackMode) releaseVocalPlayer() + return false + } + if (embeddedDualTrackMode && currentVocalPath == path && embeddedVocalEngine != null) { + if (!vocalPlayerPrepared && embeddedMainTrackOriginal != originalVocal) { + releaseVocalPlayer() + } else { + applyVocalVolume() + return vocalPlayerPrepared + } + } + + releaseVocalPlayer() + return try { + embeddedDualTrackMode = true + embeddedMainTrackOriginal = originalVocal + pendingVocalPosition = adjustedAudioPosition(positionMs) + currentVocalPath = path + val candidate = KtvPlaybackEngine(this) + embeddedVocalEngine = candidate + vocalPlayerPrepared = false + candidate.setVolume(0f, 0f) + candidate.setOnCompletionListener(null) + candidate.setOnPreparedListener { + if (embeddedVocalEngine !== candidate || currentVocalPath != path || !embeddedDualTrackMode) { + candidate.release() + return@setOnPreparedListener + } + if (candidate.audioTrackCount() < 2 || + !candidate.selectAudioTrack(!embeddedMainTrackOriginal) + ) { + Log.w( + TAG, + "Embedded seamless vocal unavailable: tracks=${candidate.audioTrackCount()} path=$path", + ) + releaseVocalPlayer() + return@setOnPreparedListener + } + vocalPlayerPrepared = true + // The secondary decoder starts roughly one audio buffer behind the visible player. + // Give it a small head start once, while it is still muted, so later volume-only + // switches land on the same presentation timestamp. + val synchronizedPosition = adjustedAudioPosition( + (player?.currentPosition ?: pendingVocalPosition) + 120, + ) + if (synchronizedPosition > 0) candidate.seekTo(synchronizedPosition) + applyVocalVolume() + if (playWhenPrepared) startVocalIfReady() else pauseVocalIfReady() + Log.i( + TAG, + "Embedded seamless vocal ready tracks=${candidate.audioTrackCount()} " + + "mainOriginal=$embeddedMainTrackOriginal path=$path", + ) + } + candidate.setOnErrorListener { _, what, extra -> + Log.w(TAG, "Embedded seamless vocal failed what=$what extra=$extra path=$path") + if (embeddedVocalEngine === candidate) releaseVocalPlayer() + true + } + candidate.setVideoUri(Uri.fromFile(File(path))) + false + } catch (e: Exception) { + Log.w(TAG, "Embedded seamless vocal prepare failed path=$path", e) + releaseVocalPlayer() + false + } + } + + private fun selectedVocalPath(song: Song?): String { + if (song == null) return "" + val direct = if (originalVocal) song.originalPath else song.accompanyPath + val databasePath = if (originalVocal) song.pOrigin else song.pAccomp + return direct?.takeIf { it.isNotBlank() && it !in setOf("0", "1", "2") } + ?: databasePath?.takeIf { it.isNotBlank() && it !in setOf("0", "1", "2") } + ?: "" + } + + private fun applyVocalVolume() { + if (vocalPlayer == null && embeddedVocalEngine == null) return + val alternateTrackActive = embeddedDualTrackMode && originalVocal != embeddedMainTrackOriginal + val volume = if ("静音练唱" == singMode || embeddedDualTrackMode && !alternateTrackActive) { + 0f + } else { + max(0f, min(1f, musicVolume / 100f)) + } + try { + if (embeddedDualTrackMode) { + embeddedVocalEngine?.setVolume(volume, volume) + } else { + vocalPlayer?.setVolume(volume, volume) + } + } catch (ignored: Exception) { + } + } + + private fun startVocalIfReady() { + if (!vocalPlayerPrepared) return + try { + if (embeddedDualTrackMode) { + if (embeddedVocalEngine?.isPlaying != true) embeddedVocalEngine?.start() + } else if (vocalPlayer?.isPlaying != true) { + vocalPlayer?.start() + } + } catch (ignored: Exception) { + } + } + + private fun pauseVocalIfReady() { + if (!vocalPlayerPrepared) return + try { + if (embeddedDualTrackMode) { + if (embeddedVocalEngine?.isPlaying == true) embeddedVocalEngine?.pause() + } else if (vocalPlayer?.isPlaying == true) { + vocalPlayer?.pause() + } + } catch (ignored: Exception) { + } + } + + private fun releaseVocalPlayer() { + embeddedVocalEngine?.release() + embeddedVocalEngine = null + if (vocalPlayer != null) { + try { + vocalPlayer!!.release() + } catch (ignored: Exception) { + } + } + vocalPlayer = null + vocalPlayerPrepared = false + embeddedDualTrackMode = false + embeddedMainTrackOriginal = true + pendingVocalPosition = 0 + currentVocalPath = "" + } + + private fun adjustedAudioPosition(videoPositionMs: Int): Int { + return max(0, videoPositionMs - audioDelayMs) + } + + private fun loadLyrics(song: Song?) { + lyricLines.clear() + if (song == null || !hasUrl(song.lyricPath)) { + showLyricText("暂无歌词", "") + return + } + val lyricPath = song.lyricPath.orEmpty() + val file = File(lyricPath) + if (!file.exists() || !file.getName().lowercase().endsWith(".lrc")) { + showLyricText("歌词文件:" + file.getName(), "暂仅同步 LRC 歌词") + return + } + try { + BufferedReader(InputStreamReader(FileInputStream(file), "UTF-8")).use { reader -> + var line: String? + while ((reader.readLine().also { line = it }) != null) parseLyricLine(line) + Collections.sort( + lyricLines, Comparator.comparingInt( + ToIntFunction { item: LyricLine? -> item!!.timeMs }) + ) + updateLyricView() + } + } catch (e: Exception) { + showLyricText("歌词读取失败", e.message) + } + } + + private fun parseLyricLine(line: String?) { + val value = if (line == null) "" else line.trim { it <= ' ' } + if (value.isEmpty()) return + val lastBracket = value.lastIndexOf(']') + if (!value.startsWith("[") || lastBracket < 0 || lastBracket >= value.length - 1) return + val text = value.substring(lastBracket + 1).trim { it <= ' ' } + if (text.isEmpty()) return + var index = 0 + while (index < lastBracket) { + val open = value.indexOf('[', index) + val close = value.indexOf(']', open + 1) + if (open < 0 || close < 0 || close > lastBracket) break + val time = parseLyricTime(value.substring(open + 1, close)) + if (time >= 0) lyricLines.add(LyricLine(time, text)) + index = close + 1 + } + } + + private fun parseLyricTime(value: String): Int { + try { + val parts = value.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (parts.size != 2) return -1 + val minutes = parts[0].toInt() + val sec = parts[1].split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val seconds = sec[0].toInt() + var millis = 0 + if (sec.size > 1) { + val fraction = (sec[1] + "00").substring(0, 2) + millis = fraction.toInt() * 10 + } + return minutes * 60000 + seconds * 1000 + millis + } catch (e: Exception) { + return -1 + } + } + + private fun updateLyricView() { + if (lyricCurrent == null || lyricNext == null || lyricLines.isEmpty() || player == null) return + val pos = player!!.currentPosition + var current = 0 + for (i in lyricLines.indices) { + if (lyricLines.get(i)!!.timeMs <= pos) current = i + else break + } + val next = if (current + 1 < lyricLines.size) lyricLines.get(current + 1)!!.text else "" + showLyricText(lyricLines.get(current)!!.text, next) + } + + private fun clearLyrics() { + lyricLines.clear() + showLyricText("暂无歌词", "") + } + + private fun showLyricText(current: String?, next: String?) { + if (lyricCurrent != null) lyricCurrent!!.setText(if (current == null) "" else current) + if (lyricNext != null) lyricNext!!.setText(if (next == null) "" else next) + } + + private fun moveSelectedQueueToTop() { + if (queueList == null || orderQueue!!.isEmpty()) return + var pos = queueList!!.getCheckedItemPosition() + if (pos < 0) pos = queueList!!.getSelectedItemPosition() + if (pos > 0 && pos < orderQueue.size) { + val song = orderQueue.removeAt(pos) + orderQueue.add(0, song) + saveState() + renderQueue() + } + } + + private fun removeSelectedQueue() { + if (queueList == null || orderQueue!!.isEmpty()) return + var pos = queueList!!.getCheckedItemPosition() + if (pos < 0) pos = queueList!!.getSelectedItemPosition() + if (pos >= 0 && pos < orderQueue.size) { + orderQueue.removeAt(pos) + saveState() + renderQueue() + } + } + + private fun showSongActions(song: Song) { + val hasLocalFile = hasValidLocalSongFile(song) + val isFavorite = store.isFavorite(song) + val panel = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(28), dp(8), dp(28), dp(22)) + } + val title = TextView(this).apply { + text = song.title.orEmpty() + textSize = 24f + gravity = Gravity.CENTER + setTextColor(Color.WHITE) + setPadding(0, dp(8), 0, dp(20)) + } + panel.addView(title, LinearLayout.LayoutParams(-1, -2)) + val actions = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER } + lateinit var dialog: AlertDialog + fun action(iconRes: Int, labelText: String, iconTint: Int? = null, block: () -> Unit): View = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + isFocusable = true + isClickable = true + setBackgroundResource(R.drawable.bg_song_action_focus) + addView(ImageView(this@MainActivity).apply { + setImageResource(iconRes) + iconTint?.let { imageTintList = ColorStateList.valueOf(it) } + setPadding(dp(18), dp(18), dp(18), dp(18)) + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(Color.rgb(92, 92, 97)) + } + }, LinearLayout.LayoutParams(dp(76), dp(76))) + addView(TextView(this@MainActivity).apply { + text = labelText; textSize = 17f; gravity = Gravity.CENTER; setTextColor(Color.WHITE) + setPadding(0, dp(8), 0, 0) + }, LinearLayout.LayoutParams(-1, -2)) + setOnClickListener { dialog.dismiss(); block() } + } + actions.addView(action(R.drawable.ott_ic_top, "置顶") { + if (!orderQueue!!.contains(song)) addToQueue(song) + moveQueuedSongToNext(song) + toast("已置顶:${song.title}") + }, LinearLayout.LayoutParams(0, -2, 1f)) + actions.addView(action( + if (isFavorite) R.drawable.ott_ic_favorite_cancel else R.drawable.ott_ic_favorite, + if (isFavorite) "取消收藏" else "收藏", + if (isFavorite) Color.rgb(233, 30, 99) else null, + ) { + val added = store.toggleFavorite(song) + saveState() + toast(if (added) "已收藏" else "已取消收藏") + if (browseMode == "favorites") { + visibleSongs.removeAll { stableId(it) == stableId(song) } + renderSongList() + } + }, LinearLayout.LayoutParams(0, -2, 1f)) + if (hasLocalFile) { + actions.addView(action(R.drawable.ott_ic_delete, "删除文件") { + confirmDeleteLocalFile(song) + }, LinearLayout.LayoutParams(0, -2, 1f)) + } + panel.addView(actions, LinearLayout.LayoutParams(-1, -2)) + panel.addView(TextView(this).apply { + text = song.singer.orEmpty() + textSize = 20f + gravity = Gravity.CENTER_VERTICAL + setTextColor(Color.WHITE) + setPadding(dp(24), 0, dp(24), 0) + setBackgroundResource(R.drawable.ott_bg_item) + isFocusable = false + isClickable = false + }, LinearLayout.LayoutParams(-1, dp(70)).apply { topMargin = dp(22) }) + dialog = AlertDialog.Builder(this).setView(panel).create() + dialog.showForTv(afterShow = { actions.getChildAt(0)?.requestFocus() }) + } + + private fun hasValidLocalSongFile(song: Song): Boolean { + val direct = song.path?.let(::File) + if (direct != null && SongFileValidator.inspect( + direct, + SongFileValidator.requiresTransportStream(direct), + ).valid + ) return true + return isDownloaded(song) + } + + private fun confirmDeleteLocalFile(song: Song) { + AlertDialog.Builder(this) + .setTitle("删除本地文件") + .setMessage("确定删除 ${song.title} 的本地文件吗?歌曲仍保留在曲库中。") + .setNegativeButton("取消", null) + .setPositiveButton("确定") { _, _ -> deleteDownloadedSong(song) } + .create().showForTv() + } + + private fun showSongInfo(song: Song) { + val message = ("歌名:" + song.title + + "\n歌手:" + song.singer + + "\n分类:" + song.category + + "\n语种:" + song.language + + "\n拼音:" + song.pinyin + + "\n原唱资源:" + resourceState(song.originalUrl, song.originalPath) + + "\n伴唱资源:" + resourceState(song.accompanyUrl, song.accompanyPath) + + "\n歌词资源:" + resourceState(song.lyricUrl, song.lyricPath) + + "\n来源:" + (if (song.remote) "网络曲库" else song.path)) + AlertDialog.Builder(this).setTitle("歌曲信息").setMessage(message) + .setPositiveButton("确定", null).create().showForTv(DialogInterface.BUTTON_POSITIVE) + } + + private fun resourceState(remoteUrl: String?, localPath: String?): String { + if (hasUrl(localPath)) return "本地已下载" + if (hasUrl(remoteUrl)) return "网络可下载" + return "无" + } + + private fun confirmRemoveSong(song: Song) { + val title = if (song.remote) "从网络曲库移除" else "删除本地文件" + val message = if (song.remote) + "该操作会在本机隐藏这首网络曲库歌曲。" + else + "该操作会删除本地文件,并从已点、收藏和歌单中移除。" + AlertDialog.Builder(this) + .setTitle(title) + .setMessage(message + "\n\n" + song.displayTitle()) + .setPositiveButton( + "确定", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> removeSong(song) }) + .setNegativeButton("取消", null) + .create().showForTv() + } + + private fun removeSong(song: Song) { + val id = stableId(song) + if (!song.remote && song.path != null && !song.path!!.isEmpty()) { + val file = File(song.path.orEmpty()) + if (file.exists() && !file.delete()) { + toast("文件删除失败") + return + } + } + store.hiddenSongIds.add(id) + store.favoriteIds.remove(id) + for (ids in store.playlistSongs.values) ids.remove(id) + removeSongFromList(orderQueue!!, id) + removeSongFromList(sangHistory, id) + saveState() + refreshLibrary(Runnable { + toast("已移除:" + song.title) + if ("收藏" == currentPage) showFavorites() + else if ("已点" == currentPage) showQueue() + else showSongs("全部") + }) + } + + private fun removeSongFromList(songs: MutableList, id: String?) { + for (i in songs.indices.reversed()) { + if (stableId(songs.get(i)) == id) songs.removeAt(i) + } + } + + private fun showCreatePlaylistDialog() { + val input = EditText(this) + input.setSingleLine(true) + input.setHint("歌单名称") + AlertDialog.Builder(this) + .setTitle("新建歌单") + .setView(input) + .setPositiveButton( + "创建", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + val name = input.getText().toString().trim { it <= ' ' } + if (!name.isEmpty() && !store.playlists.contains(name)) { + store.playlists.add(name) + saveState() + } + showPlaylists() + }) + .setNegativeButton("取消", null) + .create().showForTv() + } + + private fun fetchCatalog() { + val url: String = prefs()!!.getString(KEY_CATALOG_URL, "")!! + if (url.trim { it <= ' ' }.isEmpty()) { + showCatalogDialog() + return + } + busy(true, "正在同步网络曲库...") + io.execute(Runnable { + try { + val array = CatalogClient().fetch(url) + library.saveRemote(array) + library.scanLocal() + main.post(Runnable { + busy(false, "网络曲库已同步:" + array.length() + " 首") + if (currentTabIndex == 6) loadDownloadedList() + else showSongs("网络") + }) + } catch (e: Exception) { + main.post(Runnable { busy(false, "网络曲库同步失败:" + e.message) }) + } + }) + } + + private fun refreshLibrary(after: Runnable?) { + busy(true, "正在扫描曲库...") + io.execute(Runnable { + val locals = library.scanLocal().size + library.loadCachedRemote() + main.post(Runnable { + busy( + false, + "曲库已更新:本地 " + locals + " 首,总计 " + library.allSongs().size + " 首" + ) + if (after != null) after.run() + }) + }) + } + + private fun showCatalogDialog() { + val input = EditText(this) + input.setSingleLine(true) + input.setText(prefs()!!.getString(KEY_CATALOG_URL, "")) + input.setHint("http://你的服务器/catalog.json") + AlertDialog.Builder(this) + .setTitle("网络曲库源") + .setMessage("JSON: [{\"title\":\"歌名\",\"singer\":\"歌手\",\"category\":\"分类\",\"url\":\"http://.../song.mp4\"}]") + .setView(input) + .setPositiveButton( + "保存并同步", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + prefs()!!.edit() + .putString(KEY_CATALOG_URL, input.getText().toString().trim { it <= ' ' }) + .apply() + fetchCatalog() + }) + .setNegativeButton("取消", null) + .create().showForTv() + } + + private fun showKeyboardDialog() { + if (search == null) return + val box = LinearLayout(this) + box.setOrientation(LinearLayout.VERTICAL) + box.setPadding(dp(12), dp(12), dp(12), dp(12)) + val preview = label(search!!.getText().toString(), 22, Color.WHITE) + preview.setMinHeight(dp(54)) + preview.setBackgroundColor(PANEL_2) + box.addView(preview, LinearLayout.LayoutParams(-1, dp(58))) + + val grid = GridLayout(this) + grid.setColumnCount(8) + val keys = arrayOf( + "A", "B", "C", "D", "E", "F", "G", "H", + "I", "J", "K", "L", "M", "N", "O", "P", + "Q", "R", "S", "T", "U", "V", "W", "X", + "Y", "Z", "0", "1", "2", "3", "4", "5", + "6", "7", "8", "9", "删", "清", "空格", "搜索" + ) + val dialog = AlertDialog.Builder(this).setTitle("拼音搜索").setView(box).create() + for (key in keys) { + val keyView = button(key, View.OnClickListener { v: View? -> + val value = (v as TextView).getText().toString() + var text = preview.getText().toString() + if ("删" == value) { + text = if (text.length > 0) text.substring(0, text.length - 1) else "" + } else if ("清" == value) { + text = "" + } else if ("空格" == value) { + text += " " + } else if ("搜索" == value) { + search!!.setText(text) + filterSongs("全部", text) + dialog.dismiss() + return@OnClickListener + } else { + text += value + } + preview.setText(text) + search!!.setText(text) + }) + keyView.setGravity(Gravity.CENTER) + val lp = GridLayout.LayoutParams() + lp.width = dp(if ("空格" == key || "搜索" == key) 126 else 72) + lp.height = dp(58) + lp.setMargins(dp(5), dp(5), dp(5), dp(5)) + grid.addView(keyView, lp) + } + box.addView(grid) + dialog.setOnShowListener(OnShowListener { d: DialogInterface? -> + if (grid.getChildCount() > 0) grid.getChildAt(0).requestFocus() + }) + dialog.restoreSourceFocusOnDismiss() + dialog.show() + } + + private fun renderSongs() { + if (songAdapter == null) return + songAdapter!!.clear() + for (i in visibleSongs.indices) { + val song = visibleSongs.get(i) + songAdapter!!.add( + String.format( + Locale.ROOT, + "%02d %s%s - %s [%s/%s]%s", + i + 1, + if (store.isFavorite(song)) "★ " else "", + song.title, + song.singer, + song.category, + song.language, + if (song.remote) " 下载" else " 点歌" + ) + ) + } + songAdapter!!.notifyDataSetChanged() + header!!.setText(currentPage + " " + visibleSongs.size) + } + + private fun renderQueue() { + if (queueAdapter == null) return + queueAdapter!!.clear() + for (i in orderQueue!!.indices) queueAdapter!!.add( + (i + 1).toString() + ". " + orderQueue.get( + i + ).displayTitle() + ) + queueAdapter!!.notifyDataSetChanged() + } + + private fun addNav(text: String?, listener: View.OnClickListener) { + val index = navButtons.size + val btn = button(text, listener) + // 注册到列表以便维护选中态 + navButtons.add(btn) + // 点击时更新选中态 + btn.setOnClickListener(View.OnClickListener { v: View? -> + selectNav(index) + listener.onClick(v) + }) + nav!!.addView(btn, LinearLayout.LayoutParams(-1, dp(54))) + } + + private fun addTile( + grid: GridLayout, + title: String?, + sub: String?, + listener: View.OnClickListener? + ) { + // 卡片式磁贴:圆角背景 + 描边 + 焦点高亮 + val tile = label(title + "\n" + sub, 20, TEXT_WHITE) + tile.setFocusable(true) + tile.setClickable(true) + tile.setGravity(Gravity.CENTER) + tile.setMinHeight(dp(110)) + // 默认背景:深色圆角 + 浅描边 + tile.setBackground(roundedBg(PANEL, 8, DIVIDER, 1)) + tile.setOnClickListener(listener) + tile.setOnFocusChangeListener(OnFocusChangeListener { v: View?, hasFocus: Boolean -> + if (hasFocus) { + // 焦点态:青绿色圆角 + 白色描边 + v!!.setBackground(roundedBg(FOCUS, 8, Color.argb(180, 255, 255, 255), 2)) + tile.setTextColor(Color.rgb(8, 12, 18)) + } else { + v!!.setBackground(roundedBg(PANEL, 8, DIVIDER, 1)) + tile.setTextColor(TEXT_WHITE) + } + }) + val lp = GridLayout.LayoutParams() + lp.width = dp(210) + lp.height = dp(126) + lp.setMargins(dp(8), dp(8), dp(8), dp(8)) + grid.addView(tile, lp) + } + + private fun setPage(page: String) { + currentPage = page + if (header != null) header!!.setText(page) + if (listTitle != null) listTitle!!.setText(page) + } + + private fun panel(): LinearLayout { + val layout = LinearLayout(this) + layout.setOrientation(LinearLayout.VERTICAL) + layout.setPadding(dp(12), dp(12), dp(12), dp(12)) + // 圆角面板背景 + layout.setBackground(roundedBg(PANEL, 6, 0, 0)) + return layout + } + + private fun sectionTitle(text: String?): TextView { + val view = label(text, 22, GOLD) + view.setMinHeight(dp(46)) + // 标题底部加一条浅色分割线 + view.setPadding(dp(8), dp(6), dp(8), dp(10)) + return view + } + + // ===== TV 布局视图字段 ===== + /** 视频背景 */ + private var videoBackground: KtvVideoView? = null + private var playerLayerRoot: FrameLayout? = null + private var playerFocusBorder: View? = null + private var playerTouchOverlay: View? = null + private var homePlayerHost: View? = null + private var subPagePlayerHost: View? = null + private var activePlayerHost: View? = null + private var lastHomeFocusSource: View? = null + private var lastHomeFocusTarget: View? = null + private var lastHomeFocusReverseKey: Int = KeyEvent.KEYCODE_UNKNOWN + + /** 顶部状态栏区域 */ + private val topStatusBar: LinearLayout? = null + /** 当前播放提示 */ + /** 下载速度提示 */ + private var textDownloadSpeed: TextView? = null + + /** 下载进度容器 */ + private var downloadProgressContainer: LinearLayout? = null + + /** 下载状态文字 */ + private var textDownloadStatus: TextView? = null + + /** 下载进度条 */ + private var downloadProgressBar: ProgressBar? = null + + /** Tab 容器 */ + private var tabContainer: LinearLayout? = null + + /** 已点列表按钮 */ + + /** 已点数量徽标 */ + private var textOrderBadge: TextView? = null + /** 搜索按钮 */ + /** 搜索输入框 */ + private var searchInput: EditText? = null + + /** 搜索执行按钮 */ + private var btnDoSearch: TextView? = null + + /** 分类列表容器 */ + private var categoryList: LinearLayout? = null + + /** 列表标题 */ + private var listTitle: TextView? = null + + /** 分页信息 */ + private var textPageInfo: TextView? = null + + /** 上一页按钮 */ + private var btnPrevPage: TextView? = null + + /** 下一页按钮 */ + private var btnNextPage: TextView? = null + + /** 当前播放歌曲标题 */ + private var playerSongTitle: TextView? = null + + /** 当前播放歌手 */ + private var playerSongSinger: TextView? = null + /** 原唱/伴唱切换按钮 */ + /** 原唱/伴唱文本 */ + private val textVocalMode: TextView? = null + private var breadcrumbCurrent: TextView? = null + + /** 播放/暂停文本 */ + private val textPlayPause: TextView? = null + + /** 切歌按钮 */ + private val btnNextSong: TextView? = null + + /** 重唱按钮 */ + private val btnReplay: TextView? = null + + /** 音量按钮 */ + private val btnVolume: TextView? = null + /** ===== 新版UI字段(顶栏/底栏/首页3列) ===== */ + /** 顶部状态栏 */ + private var topBar: View? = null + + /** 底部控制栏 */ + private var bottomBar: View? = null + + /** 顶栏 logo */ + private var textLogo: ImageView? = null + private var topBrandName: TextView? = null + private var contentFrame: FrameLayout? = null + private var browsePagination: View? = null + + /** 顶栏搜索按钮 */ + private var btnTopSearch: TextView? = null + + /** 顶栏已点按钮(整条) */ + private var btnTopOrder: LinearLayout? = null + private var textTopOrderLabel: TextView? = null + + /** 已点数量徽章 */ + private var textTopOrderBadge: TextView? = null + + /** 顶栏伴唱按钮 */ + private var btnTopVocal: TextView? = null + + /** 顶栏切歌按钮 */ + private var btnTopNext: TextView? = null + + /** 顶栏暂停按钮 */ + private var btnTopPause: TextView? = null + + /** 顶栏重唱按钮 */ + private var btnTopReplay: TextView? = null + + /** 顶栏调音按钮 */ + private var btnTopTone: TextView? = null + + /** 底栏猜你想唱 */ + private var btnBottomGuess: TextView? = null + + /** 底栏设置 */ + private var btnBottomSettings: TextView? = null + + /** 底栏扫码点歌 */ + private var btnBottomMobileOrder: TextView? = null + + /** 底栏退出 */ + private var btnBottomExit: TextView? = null + + /** 首页 3 列布局根 */ + private var homeLayout: LinearLayout? = null + + /** 首页视频窗口 */ + private var homeVideo: KtvVideoView? = null + + /** 首页视频标题 */ + private var homePlayerTitle: TextView? = null + + /** 首页视频歌手 */ + private var homePlayerSinger: TextView? = null + + /** 首页4个分类卡片 */ + private var homeCardRank: TextView? = null + private var homeCardSong: TextView? = null + private var homeCardSinger: TextView? = null + private var homeCardLocal: TextView? = null + private var homeCardRegular: TextView? = null + private var homeCardFavorite: TextView? = null + private var homeCardCategory: TextView? = null + + /** 新歌榜海报分页 */ + private var textPosterPage: TextView? = null + private var homePosterView: View? = null + + /** 当前歌曲顶部提示(用于子页面左列) */ + private var textCurrentSongTip: TextView? = null + + /** 子页面返回按钮(右栏顶部) */ + private var btnBack: TextView? = null + + /** 品牌标识 */ + private var textBrandRight: TextView? = null + + /** 左侧栏布局引用 */ + private var leftColumn: LinearLayout? = null + private var rightColumn: LinearLayout? = null + + /** 子页面二级分类滚动容器(语种筛选) */ + private var subCategoryScroll: HorizontalScrollView? = null + private var keyboardArea: View? = null + private var rankCategoryGrid: GridLayout? = null + private var paginationBar: View? = null + private var fullScreenContainer: FrameLayout? = null + private var isFullScreen = false + private var playerReturnSurface: KtvVideoView? = null + private var playerReturnHost: View? = null + private var fullScreenPauseText: TextView? = null + private var fullScreenVocalText: TextView? = null + private var fullScreenPauseIcon: ImageView? = null + private var fullScreenVocalIcon: ImageView? = null + private var fullScreenSongInfo: TextView? = null + private var fullScreenSeekBar: SeekBar? = null + private var fullScreenTime: TextView? = null + private var fullScreenSeeking = false + private var fullScreenControls: LinearLayout? = null + private var fullScreenProgressRow: View? = null + private var fullScreenToneControl: View? = null + private var fullScreenVolumePanel: View? = null + private var fullScreenVolumeIndicator: CircularVolumeView? = null + private var fullScreenVolumeMinusButton: TextView? = null + private var fullScreenVolumePlusButton: TextView? = null + private var fullScreenVolumeMuteButton: TextView? = null + private var fullScreenPreviousVolume = 70 + private var randomLocalRequestSerial = 0L + private var lastRandomLocalSongId: String? = null + private var advancingPlayback = false + private var lastAutoAdvanceAtMs = 0L + private var fullScreenChromeVisible = false + private val hideFullScreenVolumeIndicatorRunnable = Runnable { + fullScreenVolumeIndicator?.animate()?.alpha(0f)?.setDuration(220L)?.withEndAction { + fullScreenVolumeIndicator?.visibility = View.GONE + fullScreenVolumeIndicator?.alpha = 1f + }?.start() + } + private val hideFullScreenChromeRunnable = Runnable { + if (isFullScreen && fullScreenVolumePanel?.visibility != View.VISIBLE) { + setFullScreenChromeVisible(false) + } + } + private var playbackModeNotice: TextView? = null + private var songIntroNotice: TextView? = null + private var songIntroIcon: TextView? = null + private val hidePlaybackModeNotice = Runnable { + playbackModeNotice?.animate()?.alpha(0f)?.setDuration(180L)?.withEndAction { + playbackModeNotice?.visibility = View.GONE + }?.start() + } + private val hideSongIntroNotice = Runnable { + songIntroNotice?.animate()?.alpha(0f)?.setDuration(250L)?.withEndAction { + songIntroNotice?.visibility = View.GONE + songIntroIcon?.visibility = View.GONE + }?.start() + } + + /** 当前选中的 Tab 索引 */ + private var currentTabIndex = -1 // -1 = 首页 + + /** 当前分类列表 */ + private val currentCategories: MutableList = ArrayList() + + /** 排行榜显示名对应的数据库 playlist id,索引与 currentCategories 一致。 */ + private val rankPlaylistIds: MutableList = ArrayList() + + /** 当前分类索引 */ + private var currentCategoryIndex = 0 + + private var categoryParentPage = 0 + private var categoryParentQuery = "" + + private fun label(text: String?, sp: Int, color: Int): TextView { + val view = TextView(this) + view.setText(text) + view.setTextSize(sp.toFloat()) + view.setTextColor(color) + view.setGravity(Gravity.CENTER_VERTICAL) + view.setPadding(dp(8), dp(6), dp(8), dp(6)) + return view + } + + private fun setTopControlIcon(view: TextView?, drawableRes: Int) { + if (view == null) return + val icon = androidx.appcompat.content.res.AppCompatResources.getDrawable(this, drawableRes)?.mutate() + icon?.setBounds(0, 0, dp(18), dp(18)) + view.setCompoundDrawablesRelative(icon, null, null, null) + view.compoundDrawablePadding = dp(5) + } + + private fun button(text: String?, listener: View.OnClickListener?): TextView { + val view = label(text, 18, TEXT_WHITE) + view.setFocusable(true) + view.setClickable(true) + view.setMinHeight(dp(50)) + view.setOnClickListener(listener) + // 焦点态:圆角青绿色背景;失去焦点:透明 + view.setOnFocusChangeListener(OnFocusChangeListener { v: View?, hasFocus: Boolean -> + if (hasFocus) { + val gd = GradientDrawable() + gd.setColor(FOCUS) + gd.setCornerRadius(dp(6).toFloat()) + gd.setStroke(dp(1), Color.argb(100, 255, 255, 255)) + v!!.setBackground(gd) + view.setTextColor(Color.rgb(8, 12, 18)) + } else { + v!!.setBackgroundColor(Color.TRANSPARENT) + view.setTextColor(TEXT_WHITE) + } + }) + return view + } + + /** + * 创建圆角面板背景 drawable + * @param bgColor 背景色 + * @param cornerRadiusDp 圆角半径(dp) + * @param strokeColor 描边色(0 表示无描边) + * @param strokeWidthDp 描边宽度(dp) + * @return GradientDrawable 圆角背景 + */ + private fun roundedBg( + bgColor: Int, + cornerRadiusDp: Int, + strokeColor: Int, + strokeWidthDp: Int + ): GradientDrawable { + val gd = GradientDrawable() + gd.setColor(bgColor) + gd.setCornerRadius(dp(cornerRadiusDp).toFloat()) + if (strokeColor != 0 && strokeWidthDp > 0) { + gd.setStroke(dp(strokeWidthDp), strokeColor) + } + return gd + } + + private fun listView(): ListView { + val view = ListView(this) + view.setBackgroundColor(PANEL_2) + view.setCacheColorHint(Color.TRANSPARENT) + // 分割线:半透明白色,1px 高 + view.setDivider(ColorDrawable(DIVIDER)) + view.setDividerHeight(dp(1)) + view.setFocusable(true) + return view + } + + private fun tvAdapter(): ArrayAdapter { + return object : + ArrayAdapter(this, android.R.layout.simple_list_item_1, ArrayList()) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) as TextView + view.setTextColor(TEXT_WHITE) + view.setTextSize(18f) + view.setMinHeight(dp(48)) + view.setGravity(Gravity.CENTER_VERTICAL) + view.setPadding(dp(14), dp(4), dp(14), dp(4)) + return view + } + } + } + + /** + * 初始化 TV 布局的视图组件和事件监听 + */ + private fun initTvLayout() { + // 绑定主布局视图组件 + val subPageVideoPlaceholder = findViewById(R.id.video_background) + playerLayerRoot = findViewById(R.id.layout_root) + homePlayerHost = findViewById(R.id.home_player_container) + subPagePlayerHost = findViewById(R.id.player_container) + textDownloadSpeed = findViewById(R.id.text_download_speed) + downloadProgressContainer = findViewById(R.id.download_progress_container) + textDownloadStatus = findViewById(R.id.text_download_status) + downloadProgressBar = findViewById(R.id.download_progress_bar) + tabContainer = findViewById(R.id.tab_container) + // 子页面沿用顶部已点数字徽章。 + textOrderBadge = null + searchInput = findViewById(R.id.search_input) + btnDoSearch = findViewById(R.id.btn_do_search) + categoryList = findViewById(R.id.category_list) + textPageInfo = findViewById(R.id.text_page_info) + btnPrevPage = findViewById(R.id.btn_prev_page) + btnNextPage = findViewById(R.id.btn_next_page) + songList = findViewById(R.id.song_list) + playerSongTitle = findViewById(R.id.player_song_title) + playerSongSinger = findViewById(R.id.player_song_singer) + breadcrumbCurrent = findViewById(R.id.breadcrumb_current) + // 新布局不显示独立列表标题,不能让业务标题覆盖面包屑。 + listTitle = TextView(this) + val breadcrumbHome = findViewById(R.id.breadcrumb_home) + if (breadcrumbHome != null) breadcrumbHome.setOnClickListener { navigateBackToHome() } + + // ===== 顶栏/底栏控件 ===== + topBar = findViewById(R.id.top_bar) + bottomBar = findViewById(R.id.bottom_bar) + textLogo = findViewById(R.id.text_logo) + topBrandName = findViewById(R.id.top_brand_name) + contentFrame = findViewById(R.id.content_frame) + browsePagination = findViewById(R.id.browse_pagination) + btnTopSearch = findViewById(R.id.btn_top_search) + btnTopOrder = findViewById(R.id.btn_top_order) + textTopOrderLabel = findViewById(R.id.text_top_order_label) + textTopOrderBadge = findViewById(R.id.text_top_order_badge) + btnTopVocal = findViewById(R.id.btn_top_vocal) + btnTopNext = findViewById(R.id.btn_top_next) + btnTopPause = findViewById(R.id.btn_top_pause) + btnTopReplay = findViewById(R.id.btn_top_replay) + btnTopTone = findViewById(R.id.btn_top_tone) + btnTopVocal?.text = vocalActionText() + btnTopPause?.text = if (playWhenPrepared) "暂停" else "播放" + setTopControlIcon(textTopOrderLabel, R.drawable.ott_ic_ctrl_orderlist) + setTopControlIcon(btnTopVocal, vocalActionIcon()) + setTopControlIcon(btnTopNext, R.drawable.ott_ic_ctrl_switch_song) + setTopControlIcon( + btnTopPause, + if (playWhenPrepared) R.drawable.ott_ic_ctrl_pause else R.drawable.ott_ic_ctrl_play, + ) + setTopControlIcon(btnTopReplay, R.drawable.ott_ic_ctrl_replay) + setTopControlIcon(btnTopTone, R.drawable.ott_ic_ctrl_volume) + btnBottomGuess = findViewById(R.id.btn_bottom_guess) + btnBottomMobileOrder = findViewById(R.id.btn_bottom_mobile_order) + btnBottomSettings = findViewById(R.id.btn_bottom_settings) + btnBottomExit = findViewById(R.id.btn_bottom_exit) + + // ===== 首页 3 列布局 ===== + homeLayout = findViewById(R.id.home_layout) + val homeVideoPlaceholder = findViewById(R.id.home_video_background) + homePlayerTitle = findViewById(R.id.home_player_song_title) + homePlayerSinger = findViewById(R.id.home_player_song_singer) + homeCardRank = findViewById(R.id.home_card_rank) + homeCardSong = findViewById(R.id.home_card_song) + homeCardSinger = findViewById(R.id.home_card_singer) + homeCardLocal = findViewById(R.id.home_card_local) + homeCardRegular = findViewById(R.id.home_card_regular) + homeCardFavorite = findViewById(R.id.home_card_favorite) + homeCardCategory = findViewById(R.id.home_card_category) + textPosterPage = findViewById(R.id.text_poster_page) + + // ===== 子页面外壳 ===== + textCurrentSongTip = findViewById(R.id.text_current_song_tip) + btnBack = findViewById(R.id.btn_back) + textBrandRight = findViewById(R.id.text_brand_right) + leftColumn = findViewById(R.id.left_column) + rightColumn = findViewById(R.id.right_column) + subCategoryScroll = findViewById(R.id.sub_category_scroll) + keyboardArea = findViewById(R.id.keyboard_area) + rankCategoryGrid = findViewById(R.id.rank_category_grid) + paginationBar = findViewById(R.id.pagination_bar) + fullScreenContainer = findViewById(R.id.container) + + // 设置顶栏按钮事件 + if (btnTopSearch != null) btnTopSearch!!.setOnClickListener(View.OnClickListener { v: View? -> showQuickSearchPage() }) + if (btnTopOrder != null) btnTopOrder!!.setOnClickListener(View.OnClickListener { v: View? -> showOrderListPage() }) + if (btnTopVocal != null) btnTopVocal!!.setOnClickListener(View.OnClickListener { v: View? -> toggleOriginalVocal() }) + if (btnTopNext != null) btnTopNext!!.setOnClickListener(View.OnClickListener { v: View? -> playNext() }) + if (btnTopPause != null) btnTopPause!!.setOnClickListener(View.OnClickListener { v: View? -> togglePlay() }) + if (btnTopReplay != null) btnTopReplay!!.setOnClickListener(View.OnClickListener { v: View? -> replay() }) + if (btnTopTone != null) btnTopTone!!.setOnClickListener(View.OnClickListener { v: View? -> showAudioControlDialog() }) + + // 设置底栏按钮事件 + if (btnBottomGuess != null) btnBottomGuess!!.setOnClickListener(View.OnClickListener { v: View? -> showGuessPage() }) + if (btnBottomMobileOrder != null) btnBottomMobileOrder!!.setOnClickListener(View.OnClickListener { showMobileRemoteDialog() }) + if (btnBottomSettings != null) btnBottomSettings!!.setOnClickListener(View.OnClickListener { v: View? -> showSettingsPage() }) + if (btnBottomExit != null) btnBottomExit!!.setOnClickListener(View.OnClickListener { v: View? -> showExitDialog() }) + + // 设置首页4个分类卡片点击事件 + if (homeCardRank != null) homeCardRank!!.setOnClickListener(View.OnClickListener { v: View? -> loadHotSongs() }) + if (homeCardSong != null) homeCardSong!!.setOnClickListener(View.OnClickListener { v: View? -> showSearchPage() }) + if (homeCardSinger != null) homeCardSinger!!.setOnClickListener(View.OnClickListener { v: View? -> loadSingers() }) + if (homeCardLocal != null) homeCardLocal!!.setOnClickListener(View.OnClickListener { v: View? -> showLocalPage() }) + if (homeCardRegular != null) homeCardRegular!!.setOnClickListener(View.OnClickListener { v: View? -> loadSangHistory() }) + if (homeCardFavorite != null) homeCardFavorite!!.setOnClickListener(View.OnClickListener { v: View? -> loadFavorites() }) + if (homeCardCategory != null) homeCardCategory!!.setOnClickListener(View.OnClickListener { v: View? -> loadCategoryPlaylists() }) + val homePoster = findViewById(R.id.home_poster_container) + homePosterView = homePoster + textPosterPage?.text = "1/${homeBanners.size}" + main.removeCallbacks(homeBannerRunnable) + main.postDelayed(homeBannerRunnable, 5000L) + if (homePoster != null) homePoster.setOnClickListener(View.OnClickListener { v: View? -> loadHotSongs() }) + // 原版始终保留同一个视频窗口,仅在首页、子页和全屏间调整边界。 + // 两个 XML View 只用于提供精确的布局锚点,避免页面操作重建解码 Surface。 + homeVideoPlaceholder?.visibility = View.GONE + subPageVideoPlaceholder?.visibility = View.GONE + val persistentVideo = KtvVideoView(this).apply { + setOnClickListener { + if (isFullScreen) exitFullScreenPlayer() else showFullScreenPlayer() + } + } + val root = playerLayerRoot + if (root != null) { + val maskIndex = root.indexOfChild(findViewById(R.id.mask)) + val insertionIndex = if (maskIndex >= 0) maskIndex else root.childCount + root.addView(persistentVideo, insertionIndex, FrameLayout.LayoutParams(1, 1)) + playerFocusBorder = View(this).apply { + setBackgroundResource(R.drawable.bg_player_focus) + isClickable = false + isFocusable = false + visibility = View.GONE + elevation = dp(6).toFloat() + }.also { root.addView(it, insertionIndex + 1, FrameLayout.LayoutParams(1, 1)) } + playbackModeNotice = TextView(this).apply { + gravity = Gravity.CENTER + textSize = 18f + setTextColor(Color.WHITE) + visibility = View.GONE + alpha = 0f + elevation = dp(8).toFloat() + }.also { root.addView(it, insertionIndex + 1, FrameLayout.LayoutParams(dp(104), dp(104))) } + songIntroNotice = TextView(this).apply { + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(94), 0, dp(20), 0) + textSize = 18f + setTextColor(Color.WHITE) + maxLines = 2 + visibility = View.GONE + alpha = 0f + elevation = dp(7).toFloat() + }.also { root.addView(it, insertionIndex + 1, FrameLayout.LayoutParams(dp(330), dp(92))) } + songIntroIcon = TextView(this).apply { + text = "♫" + textSize = 34f + gravity = Gravity.CENTER + setTextColor(Color.WHITE) + background = GradientDrawable( + GradientDrawable.Orientation.TL_BR, + intArrayOf(Color.rgb(254, 78, 184), Color.rgb(87, 52, 230)) + ).apply { shape = GradientDrawable.OVAL; setStroke(dp(3), Color.argb(210, 255, 255, 255)) } + visibility = View.GONE + alpha = 0f + elevation = dp(9).toFloat() + }.also { root.addView(it, insertionIndex + 2, FrameLayout.LayoutParams(dp(78), dp(78))) } + playerTouchOverlay = View(this).apply { + setBackgroundColor(Color.TRANSPARENT) + isClickable = true + isFocusable = false + setOnClickListener { showFullScreenPlayer() } + elevation = dp(10).toFloat() + }.also { root.addView(it, insertionIndex + 1, FrameLayout.LayoutParams(1, 1)) } + } + homeVideo = persistentVideo + videoBackground = persistentVideo + player = persistentVideo + persistentVideo.bind(playbackEngine) + playbackEngine.attach(persistentVideo) + movePlayerToHost(homePlayerHost) + listOfNotNull(homePlayerHost, subPagePlayerHost).forEach(::bindPlayerHostFocus) + // The persistent video surface sits above its placeholder; forward touch entry to full screen too. + persistentVideo.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_UP) showFullScreenPlayer() + true + } + + // 设置子页面返回按钮 + if (btnBack != null) btnBack!!.setOnClickListener { navigateBack() } + + // 设置 Tab 导航 + setupTabs() + // 构建虚拟键盘 + buildKeyboard() + + // 设置搜索/翻页事件 + if (btnDoSearch != null) btnDoSearch!!.setOnClickListener(View.OnClickListener { v: View? -> performSearch() }) + if (searchInput != null) { + searchInput!!.setOnEditorActionListener(OnEditorActionListener { v: TextView?, actionId: Int, event: KeyEvent? -> + performSearch() + true + }) + searchInput!!.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + override fun afterTextChanged(text: Editable?) { + if (!suppressSearchWatcher) scheduleKeyboardSearch() + } + }) + } + if (btnPrevPage != null) btnPrevPage!!.setOnClickListener(View.OnClickListener { v: View? -> prevPage() }) + if (btnNextPage != null) btnNextPage!!.setOnClickListener(View.OnClickListener { v: View? -> nextPage() }) + listOfNotNull( + btnTopSearch, btnTopOrder, btnTopVocal, btnTopNext, btnTopPause, btnTopReplay, btnTopTone, + btnBottomGuess, btnBottomMobileOrder, btnBottomSettings, btnBottomExit, btnBack, btnPrevPage, btnNextPage, + homeCardRank, homeCardSong, homeCardSinger, homeCardLocal, homeCardRegular, + homeCardFavorite, homeCardCategory, + ).forEach(::installPressFeedback) + listOfNotNull(homePlayerHost, subPagePlayerHost, homePoster).forEach(TvFocusStyler::preserveBackground) + (homePoster as? FrameLayout)?.foreground = getDrawable(R.drawable.bg_player_focus) + configureTvFocusNavigation() + prepareTvFocusableTree(window.decorView) + + // 设置歌曲列表点击事件 + songList!!.setOnItemClickListener(OnItemClickListener { parent: AdapterView<*>?, view: View?, position: Int, id: kotlin.Long -> + if (browseMode == "settings" || browseMode == "settings_section") { + activeSettingsEntries.getOrNull(position)?.takeIf { it.checked == null }?.action?.invoke() + return@OnItemClickListener + } + if (browseMode == "quick" || browseMode == "category_list") return@OnItemClickListener + if ("singer_list" == browseMode) { + if (position < visibleSingers.size) { + val singer = visibleSingers.get(position)!! + loadSingerSongs(singer[0], singer[1]) + } + return@OnItemClickListener + } + if (position < visibleSongs.size) { + val song = visibleSongs.get(position) + if (currentTabIndex == -1) { + // 首页: 点击卡片导航 + showHomePage() + } else if (currentTabIndex == 4) { + // 已点列表点击只调整为下一首,切歌只能由明确的“切歌”操作触发。 + if (currentSong != null && currentSong!!.equals(song)) { + toast("正在播放: " + song.title) + } else { + moveQueuedSongToNext(song) + } + } else if (currentTabIndex == 5) { + // 已唱历史: 重新点歌 + addToQueue(song) + renderSongList() + } else if (currentTabIndex == 6) { + // 下载列表中的歌曲同样按点播入队,不能抢占当前播放。 + addToQueue(song) + } else if (currentTabIndex == 7) { + // 收藏: 点歌 + addToQueue(song) + renderSongList() + } else { + addToQueue(song) + renderSongList() + } + } + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { parent: AdapterView<*>?, view: View?, position: Int, id: kotlin.Long -> + if ("settings" == browseMode || "singer_list" == browseMode) return@OnItemLongClickListener false + if (position < 0 || position >= visibleSongs.size) return@OnItemLongClickListener false + val song = visibleSongs.get(position) + if (currentTabIndex == 4) { + if (currentSong != null && currentSong!!.equals(song)) { + toast("正在播放的歌曲不能删除") + return@OnItemLongClickListener true + } + orderQueue!!.remove(song) + saveState() + loadOrderedList() + toast("已删除: " + song.title) + } else { + showSongActions(song) + } + true + }) + } + + /** + * 首页 - 仿原版3列布局(视频+4卡片+新歌榜海报) + */ + private fun showHomePage() { + if (!restoringFocusRoute) focusReturnStack.clear() + browseRequestVersion++ + main.removeCallbacks(keyboardSearchRunnable) + currentPage = "首页" + currentTabIndex = -1 + currentCategories.clear() + currentCategoryIndex = -1 + clearHomeFocusTransition() + updateCategories() + // 隐藏子页面外壳,显示首页 3 列 + val shell = findViewById(R.id.main_layout) + if (shell != null) shell.setVisibility(View.GONE) + topBar?.apply { + visibility = View.VISIBLE + translationY = 0f + setBackgroundResource(R.drawable.bg_top_bar) + } + textLogo?.visibility = View.VISIBLE + topBrandName?.visibility = View.VISIBLE + btnTopSearch?.visibility = View.VISIBLE + bottomBar?.visibility = View.VISIBLE + updateContentFrameMargins(chromeBarHeight(), chromeBarHeight()) + if (homeLayout != null) homeLayout!!.setVisibility(View.VISIBLE) + configureTvFocusNavigation() + movePlayerToHost(homePlayerHost) + // 同步当前播放信息到首页 + syncPlayerInfo() + // 隐藏已点的子页面辅助控件 + if (subCategoryScroll != null) subCategoryScroll!!.setVisibility(View.GONE) + window.decorView.post { + prepareTvFocusableTree(window.decorView) + } + } + + /** + * 切换到子页面外壳(左视频+键盘 / 右内容) + * @param breadcrumb 面包屑显示文字(主页 / xxx) + */ + private fun showSubPageShell(breadcrumb: String) { + rememberReturnPointIfNeeded(breadcrumb) + browseRequestVersion++ + visibleSongs.clear() + visibleSingers.clear() + songList?.adapter = tvAdapter() + currentPage = breadcrumb + if (homeLayout != null) homeLayout!!.setVisibility(View.GONE) + clearHomeTopFocusNavigation() + topBar?.apply { + visibility = View.VISIBLE + // 紧凑横屏的内容区顶部内边距只有 10dp;继续下移 22dp 会让顶部 + // 控制按钮压到面包屑/返回栏上。窄屏只做轻微下移,保留两栏间距。 + translationY = dp(if (compactUi) 8 else 22).toFloat() + setBackgroundColor(Color.TRANSPARENT) + } + textLogo?.visibility = View.GONE + topBrandName?.visibility = View.GONE + btnTopSearch?.visibility = View.GONE + bottomBar?.visibility = View.GONE + updateContentFrameMargins(0, 0) + val shell = findViewById(R.id.main_layout) + if (shell != null) shell.setVisibility(View.VISIBLE) + if (breadcrumbCurrent != null) { + breadcrumbCurrent!!.text = breadcrumb.removePrefix("主页").ifEmpty { " / 首页" } + } + if (keyboardArea != null) keyboardArea!!.setVisibility(View.VISIBLE) + rankCategoryGrid?.visibility = View.GONE + if (tabContainer != null) tabContainer!!.setVisibility(View.VISIBLE) + if (paginationBar != null) paginationBar!!.setVisibility(View.GONE) + browsePagination?.visibility = View.VISIBLE + if (songList != null) { + songList!!.setBackgroundResource(R.drawable.bg_song_list) + songList!!.setDivider(ColorDrawable(Color.argb(34, 255, 255, 255))) + songList!!.setDividerHeight(dp(1)) + songList!!.selector = ColorDrawable(Color.TRANSPARENT) + songList!!.itemsCanFocus = true + songList!!.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + } + movePlayerToHost(subPagePlayerHost) + window.decorView.post { + prepareTvFocusableTree(window.decorView) + } + } + + private fun updateContentFrameMargins(top: Int, bottom: Int) { + val frame = contentFrame ?: return + val params = frame.layoutParams as? FrameLayout.LayoutParams ?: return + if (params.topMargin == top && params.bottomMargin == bottom) return + params.topMargin = top + params.bottomMargin = bottom + frame.layoutParams = params + } + + private fun movePlayerToHost(host: View?) { + if (host == null || isFullScreen) return + activePlayerHost = host + host.post { applyPlayerBounds(host) } + } + + private fun bindPlayerHostFocus(host: View) { + host.isClickable = true + host.isFocusable = true + host.isFocusableInTouchMode = false + host.setOnClickListener { showFullScreenPlayer() } + host.setOnFocusChangeListener { _, hasFocus -> + playerFocusBorder?.apply { + isActivated = hasFocus + visibility = if (hasFocus && !isFullScreen) View.VISIBLE else View.GONE + } + if (hasFocus && !isFullScreen) { + activePlayerHost = host + applyPlayerBounds(host) + } + } + } + + private fun applyPlayerBounds(host: View) { + val root = playerLayerRoot ?: return + val video = player ?: return + if (host.width <= 0 || host.height <= 0 || host.visibility != View.VISIBLE) return + val rootLocation = IntArray(2) + val hostLocation = IntArray(2) + root.getLocationInWindow(rootLocation) + host.getLocationInWindow(hostLocation) + val params = (video.layoutParams as? FrameLayout.LayoutParams) + ?: FrameLayout.LayoutParams(host.width, host.height) + params.width = host.width + params.height = host.height + params.leftMargin = hostLocation[0] - rootLocation[0] + params.topMargin = hostLocation[1] - rootLocation[1] + video.layoutParams = params + video.visibility = View.VISIBLE + playerFocusBorder?.let { border -> + val borderParams = (border.layoutParams as? FrameLayout.LayoutParams) + ?: FrameLayout.LayoutParams(host.width, host.height) + borderParams.width = host.width + borderParams.height = host.height + borderParams.leftMargin = params.leftMargin + borderParams.topMargin = params.topMargin + border.layoutParams = borderParams + border.isActivated = host.hasFocus() + border.visibility = if (host.hasFocus() && !isFullScreen) View.VISIBLE else View.GONE + } + playerTouchOverlay?.let { overlay -> + val overlayParams = (overlay.layoutParams as? FrameLayout.LayoutParams) + ?: FrameLayout.LayoutParams(host.width, host.height) + overlayParams.width = host.width + overlayParams.height = host.height + overlayParams.leftMargin = params.leftMargin + overlayParams.topMargin = params.topMargin + overlay.layoutParams = overlayParams + overlay.visibility = if (isFullScreen) View.GONE else View.VISIBLE + } + updatePlaybackNoticeBounds() + } + + private fun expandPlayerToFullScreen() { + val root = playerLayerRoot ?: return + val video = player ?: return + val params = (video.layoutParams as? FrameLayout.LayoutParams) + ?: FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + params.width = ViewGroup.LayoutParams.MATCH_PARENT + params.height = ViewGroup.LayoutParams.MATCH_PARENT + params.leftMargin = 0 + params.topMargin = 0 + video.layoutParams = params + playerFocusBorder?.visibility = View.GONE + video.bringToFront() + songIntroNotice?.bringToFront() + songIntroIcon?.bringToFront() + playbackModeNotice?.bringToFront() + updatePlaybackNoticeBounds() + fullScreenContainer?.bringToFront() + } + + private fun updatePlaybackNoticeBounds() { + val root = playerLayerRoot ?: return + val video = player ?: return + val videoParams = video.layoutParams as? FrameLayout.LayoutParams ?: return + val width = if (videoParams.width > 0) videoParams.width else root.width + val height = if (videoParams.height > 0) videoParams.height else root.height + playbackModeNotice?.let { notice -> + val size = dp(104) + (notice.layoutParams as? FrameLayout.LayoutParams)?.let { params -> + params.width = size; params.height = size + params.leftMargin = videoParams.leftMargin + (width - size) / 2 + params.topMargin = videoParams.topMargin + (height - size) / 2 + notice.layoutParams = params + } + } + songIntroNotice?.let { notice -> + val noticeWidth = min(dp(330), (width * 0.72f).toInt()) + val noticeHeight = dp(92) + (notice.layoutParams as? FrameLayout.LayoutParams)?.let { params -> + params.width = noticeWidth; params.height = noticeHeight + params.leftMargin = videoParams.leftMargin + (width - noticeWidth) / 2 + params.topMargin = videoParams.topMargin + (height - noticeHeight) / 2 + notice.layoutParams = params + songIntroIcon?.let { icon -> + (icon.layoutParams as? FrameLayout.LayoutParams)?.let { iconParams -> + iconParams.width = dp(78); iconParams.height = dp(78) + iconParams.leftMargin = params.leftMargin + dp(8) + iconParams.topMargin = params.topMargin + dp(7) + icon.layoutParams = iconParams + } + } + } + } + } + + private fun showPlaybackModeNotice(mode: String, persistent: Boolean = false) { + val notice = playbackModeNotice ?: return + main.removeCallbacks(hidePlaybackModeNotice) + notice.text = when (mode) { + "暂停" -> "Ⅱ\n暂停" + "原唱" -> "♟\n原唱" + else -> "♛\n伴唱" + } + notice.background = GradientDrawable( + GradientDrawable.Orientation.TL_BR, + intArrayOf(Color.rgb(151, 126, 255), Color.rgb(74, 39, 220)) + ).apply { shape = GradientDrawable.OVAL } + updatePlaybackNoticeBounds() + notice.visibility = View.VISIBLE + notice.alpha = 0f + notice.animate().alpha(1f).setDuration(140L).start() + if (!persistent) main.postDelayed(hidePlaybackModeNotice, 1400L) + } + + private fun hidePlaybackNoticeNow() { + main.removeCallbacks(hidePlaybackModeNotice) + playbackModeNotice?.visibility = View.GONE + playbackModeNotice?.alpha = 0f + } + + private fun showSongIntro(song: Song?) { + if (!songTitleSubtitleEnabled || song == null) return + val notice = songIntroNotice ?: return + main.removeCallbacks(hideSongIntroNotice) + notice.text = "${song.title}\n${song.singer.orEmpty()}" + notice.background = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(Color.rgb(163, 67, 225), Color.rgb(242, 48, 174), Color.argb(20, 242, 48, 174)) + ).apply { cornerRadius = dp(3).toFloat() } + updatePlaybackNoticeBounds() + notice.visibility = View.VISIBLE + songIntroIcon?.visibility = View.VISIBLE + notice.alpha = 0f + songIntroIcon?.alpha = 0f + notice.animate().alpha(1f).setDuration(180L).start() + songIntroIcon?.animate()?.alpha(1f)?.setDuration(180L)?.start() + main.postDelayed(hideSongIntroNotice, 3800L) + } + + /** + * 同步当前播放信息到首页和子页面的播放器覆盖层 + */ + private fun syncPlayerInfo() { + val title = if (currentSong == null) "未播放" else currentSong!!.title + val singer = if (currentSong == null) "" else currentSong!!.singer + if (homePlayerTitle != null) homePlayerTitle!!.text = currentAndNextText() + if (homePlayerSinger != null) homePlayerSinger!!.setText(singer) + if (playerSongTitle != null) playerSongTitle!!.setText(title) + if (playerSongSinger != null) playerSongSinger!!.setText(singer) + // 顶部状态栏 - 当前/下首 + if (textCurrentSongTip != null) { + val cur = + "当前: " + title + (if (singer == null || singer.isEmpty()) "" else " - " + singer) + val nxt = if (orderQueue!!.size >= 2) + orderQueue.get(1).title + (if (orderQueue.get(1).singer == null || orderQueue.get( + 1 + ).singer!!.isEmpty() + ) "" else " - " + orderQueue.get(1).singer) + else + "无" + textCurrentSongTip!!.setText(cur + " 下首: " + nxt) + } + fullScreenSongInfo?.text = currentAndNextText() + updateOrderBadge() + if (browseMode == "ordered" || browseMode == "sang" || browseMode == "downloads") { + renderSongList() + } + } + + private fun currentAndNextText(): String { + val current = currentSong?.let { song -> + song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty() + } ?: "未播放" + val currentIndex = currentSong?.let { orderQueue?.indexOf(it) } ?: -1 + val next = when { + currentIndex >= 0 -> orderQueue?.getOrNull(currentIndex + 1) + currentSong == null -> orderQueue?.firstOrNull() + else -> orderQueue?.firstOrNull { currentSong?.equals(it) != true } + }?.let { song -> song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty() } + ?: "无" + return "当前:$current 下一首:$next" + } + + /** 顶栏搜索按钮 - 跳到搜索页 */ + private fun showSearchPage() { + searchScope = "song" + activeSearchQuery = "" + setSearchText("") + showSubPageShell("主页 / 歌名") + currentTabIndex = 0 + setupTabs() + currentCategories.clear() + currentCategories.add("全部") + currentCategoryIndex = 0 + searchLanguage = "全部" + updateCategories() + performSearch() + io.execute { + val languages = if (library.muse.isAvailable()) library.muse.languages() else emptyList() + main.post { + if (currentTabIndex != 0 || searchScope != "song") return@post + currentCategories.clear() + currentCategories.add("全部") + currentCategories.addAll(languages) + currentCategoryIndex = currentCategories.indexOf(searchLanguage).coerceAtLeast(0) + updateCategories() + } + } + } + + private fun showQuickSearchPage() { + searchScope = "quick" + activeSearchQuery = "" + setSearchText("") + showSubPageShell("主页 / 快搜") + currentTabIndex = 8 + setupTabs() + currentCategories.clear() + currentCategoryIndex = -1 + updateCategories() + browseMode = "quick" + browsePage = 0 + loadQuickSearchPage("") + } + + /** 顶栏已点按钮 */ + private fun showOrderListPage() { + showSubPageShell("主页 / 已点") + currentTabIndex = 4 + setupTabs() + loadOrderedList() + } + + /** 首页"本地"卡片 - 显示已下载歌曲 */ + private fun showLocalPage() { + searchScope = "local" + activeSearchQuery = "" + setSearchText("") + currentTabIndex = 10 + showSubPageShell("主页 / 本地") + setupTabs() + keyboardArea?.visibility = View.VISIBLE + browseMode = "local" + browseParam = "全部" + browsePage = 0 + currentCategories.clear() + currentCategories.add("全部") + currentCategoryIndex = 0 + searchLanguage = "全部" + updateCategories() + loadLocalCatalogPage() + io.execute { + val languages = if (library.muse.isAvailable()) library.muse.languages() else emptyList() + main.post { + if (currentTabIndex != 10 || searchScope != "local") return@post + currentCategories.clear() + currentCategories.add("全部") + currentCategories.addAll(languages) + currentCategoryIndex = currentCategories.indexOf(searchLanguage).coerceAtLeast(0) + updateCategories() + } + } + } + + private fun showAllSongsPage() { + showSearchPage() + } + + /** 底栏"猜你想唱" - 加载推荐歌曲 */ + private fun showGuessPage() { + val root = layoutInflater.inflate(R.layout.dialog_song_recommend, null) + val grid = root.findViewById(R.id.recommend_song_grid) + val confirm = root.findViewById(R.id.button_recommend_confirm) + val dialog = AlertDialog.Builder(this).setView(root).create() + root.findViewById(R.id.image_recommend_close).setOnClickListener { dialog.dismiss() } + confirm.isEnabled = false + confirm.alpha = 0.55f + val selected = ArrayList() + confirm.setOnClickListener { + selected.forEach(::addToQueue) + dialog.dismiss() + } + dialog.setOnShowListener { + dialog.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setLayout( + (resources.displayMetrics.widthPixels * 0.87f).toInt(), + (resources.displayMetrics.heightPixels * 0.90f).toInt(), + ) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + attributes = attributes.apply { dimAmount = 0.56f } + } + TvFocusStyler.disablePlatformHighlightTree(root) + root.alpha = 0f + root.scaleX = 0.96f + root.scaleY = 0.96f + root.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(160L).start() + root.findViewById(R.id.image_recommend_close).apply { + isFocusable = true + requestFocus() + } + } + dialog.restoreSourceFocusOnDismiss() + dialog.show() + + val historySnapshot = sangHistory.toList() + val queueSnapshot = orderQueue.orEmpty().toList() + val immediateRecommendations = (historySnapshot.asSequence() + queueSnapshot.asSequence() + + library.allSongs().asSequence()) + .distinctBy(::stableId) + .take(10) + .toList() + selected.addAll(immediateRecommendations) + populateRecommendationGrid(grid, immediateRecommendations, historySnapshot) + confirm.text = "一键点歌(${immediateRecommendations.size}首)" + confirm.isEnabled = immediateRecommendations.isNotEmpty() + confirm.alpha = if (immediateRecommendations.isEmpty()) 0.55f else 1f + io.execute { + val quickSongs = library.muse.quickSongs(30) + val quickRecommendations = (historySnapshot.asSequence() + queueSnapshot.asSequence() + + quickSongs.asSequence() + immediateRecommendations.asSequence()) + .distinctBy(::stableId) + .take(10) + .toList() + main.post { + if (!dialog.isShowing) return@post + selected.clear() + selected.addAll(quickRecommendations) + populateRecommendationGrid(grid, quickRecommendations, historySnapshot) + confirm.text = "一键点歌(${quickRecommendations.size}首)" + confirm.isEnabled = quickRecommendations.isNotEmpty() + confirm.alpha = if (quickRecommendations.isEmpty()) 0.55f else 1f + } + val hotSongs = library.muse.hotSongs(0, 30) + val recommendations = (historySnapshot.asSequence() + queueSnapshot.asSequence() + + hotSongs.asSequence() + immediateRecommendations.asSequence()) + .distinctBy(::stableId) + .take(10) + .toList() + main.post { + if (!dialog.isShowing) return@post + selected.clear() + selected.addAll(recommendations) + populateRecommendationGrid(grid, recommendations, historySnapshot) + confirm.text = "一键点歌(${recommendations.size}首)" + confirm.isEnabled = recommendations.isNotEmpty() + confirm.alpha = if (recommendations.isEmpty()) 0.55f else 1f + } + } + } + + private fun populateRecommendationGrid( + grid: GridLayout, + songs: List, + history: List, + ) { + grid.removeAllViews() + songs.take(10).forEachIndexed { index, song -> + val item = FrameLayout(this).apply { + setBackgroundResource(R.drawable.ott_bg_item_not_selected) + isClickable = true + isFocusable = true + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(14), dp(5), dp(104), dp(5)) + addView(label(song.title, 16, Color.WHITE).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setPadding(0, 0, 0, 0) + }, LinearLayout.LayoutParams(-1, dp(25))) + addView(label(song.singer, 13, Color.rgb(193, 193, 193)).apply { + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setPadding(0, 0, 0, 0) + }, LinearLayout.LayoutParams(-1, dp(22))) + }, FrameLayout.LayoutParams(-1, -1)) + val historyCount = history.count { stableId(it) == stableId(song) } + val recommendationLabel = when { + historyCount > 0 -> "您共唱过${historyCount}次" + index == 3 -> "飙升榜TOP1" + index == 5 -> "新歌榜TOP5" + index == 7 -> "抖音榜TOP1" + index >= 8 -> "人气热歌" + else -> "热门TOP${index + 1}" + } + addView(label( + recommendationLabel, + 12, + if (historyCount > 0) Color.rgb(193, 193, 193) else Color.rgb(253, 51, 89), + ).apply { + gravity = Gravity.CENTER + maxLines = 1 + setPadding(0, 0, dp(8), 0) + }, FrameLayout.LayoutParams(dp(104), -1, Gravity.END)) + setOnClickListener { + addToQueue(song) + toast("已点歌:${song.title}") + } + } + installPressFeedback(item) + grid.addView(item, GridLayout.LayoutParams().apply { + rowSpec = GridLayout.spec(index / 2) + columnSpec = GridLayout.spec(index % 2) + width = (((resources.displayMetrics.widthPixels * 0.87f).toInt() - dp(28)) / 2) + height = max(dp(48), (((resources.displayMetrics.heightPixels * 0.90f).toInt() - dp(163)) / 5) - dp(6)) + setMargins(dp(2), dp(3), dp(2), dp(3)) + }) + } + } + + /** + * 设置顶部 Tab 导航栏 + */ + private fun setupTabs() { + if (tabContainer == null) return + val tabs: Array + val targets: IntArray + if (currentTabIndex >= 4 && currentTabIndex <= 6) { + tabs = arrayOf("已点", "已唱", "下载") + targets = intArrayOf(4, 5, 6) + } else if (currentTabIndex == 1 || currentTabIndex == 2 || currentTabIndex == 3 || + currentTabIndex == 7 || currentTabIndex == 8 || currentTabIndex == 9 + ) { + tabContainer!!.setVisibility(View.GONE) + return + } else { + tabs = arrayOf("全网", "本地") + targets = intArrayOf(0, 10) + } + tabContainer!!.setVisibility(View.VISIBLE) + + fun bindTab(tab: TextView, label: String, index: Int) { + tab.text = label + tab.textSize = 14f + tab.minWidth = 0 + tab.setPadding(0, 0, 0, 0) + tab.gravity = Gravity.CENTER + tab.setAccessibleFocus("focus:tab:$label", "分类,$label") + tab.isFocusable = true + tab.isClickable = true + updateTabStyle(tab, index == currentTabIndex) + tab.setOnClickListener { + currentTabIndex = index + when (index) { + 0 -> showAllSongsPage() + 1 -> loadSingers() + 2 -> loadLanguages() + 3 -> loadWordCounts() + 4 -> loadOrderedList() + 5 -> loadSangHistory() + 6 -> loadDownloadedList() + 7 -> loadFavorites() + 8 -> showSettingsPage() + 10 -> showLocalPage() + } + } + } + + val canReuse = tabContainer!!.childCount == tabs.size && tabs.indices.all { position -> + (tabContainer!!.getChildAt(position) as? TextView)?.text?.toString() == tabs[position] + } + if (canReuse) { + tabs.indices.forEach { position -> + bindTab( + tabContainer!!.getChildAt(position) as TextView, + tabs[position].orEmpty(), + targets[position], + ) + } + return + } + + val focusedTab = window.decorView.findFocus()?.takeIf { isDescendantOf(it, tabContainer) } + ?.let(::captureFocusBookmark) + tabContainer!!.removeAllViews() + for (i in tabs.indices) { + val index = targets[i] + val tab = TextView(this) + bindTab(tab, tabs[i].orEmpty(), index) + installPressFeedback(tab) + tabContainer!!.addView(tab, LinearLayout.LayoutParams(dp(76), -1)) + } + restoreFocusBookmark(focusedTab) + } + + private fun installPressFeedback(view: View) { + TvFocusStyler.install(view) + if (view.isClickable) { + view.isFocusable = true + view.isFocusableInTouchMode = false + } + view.setOnTouchListener { target, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> + target.animate().scaleX(0.94f).scaleY(0.94f).alpha(0.84f).setDuration(80L).start() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + target.animate().scaleX(1f).scaleY(1f).alpha(1f).setDuration(120L).start() + } + false + } + } + + private fun configureTvFocusNavigation() { + btnTopSearch?.nextFocusDownId = R.id.home_player_container + btnTopOrder?.nextFocusDownId = R.id.home_card_rank + btnTopVocal?.nextFocusDownId = R.id.home_card_rank + btnTopNext?.nextFocusDownId = R.id.home_card_rank + btnTopPause?.nextFocusDownId = R.id.home_card_rank + btnTopReplay?.nextFocusDownId = R.id.home_poster_container + btnTopTone?.nextFocusDownId = R.id.home_poster_container + + homePlayerHost?.nextFocusUpId = R.id.btn_top_search + homePlayerHost?.nextFocusDownId = R.id.home_card_regular + homePlayerHost?.nextFocusRightId = R.id.home_card_rank + + homeCardRank?.nextFocusUpId = R.id.btn_top_pause + homeCardRank?.nextFocusLeftId = R.id.home_player_container + homeCardRank?.nextFocusDownId = R.id.home_card_song + homeCardRank?.nextFocusRightId = R.id.home_poster_container + homeCardSong?.nextFocusUpId = R.id.home_card_rank + homeCardSong?.nextFocusDownId = R.id.home_card_singer + homeCardSong?.nextFocusRightId = R.id.home_poster_container + homeCardSinger?.nextFocusUpId = R.id.home_card_song + homeCardSinger?.nextFocusDownId = R.id.home_card_local + homeCardSinger?.nextFocusRightId = R.id.home_poster_container + homeCardLocal?.nextFocusUpId = R.id.home_card_singer + homeCardLocal?.nextFocusLeftId = R.id.home_card_category + homeCardLocal?.nextFocusDownId = R.id.btn_bottom_settings + homeCardLocal?.nextFocusRightId = R.id.home_poster_container + homeCardRegular?.nextFocusUpId = R.id.home_player_container + homeCardRegular?.nextFocusRightId = R.id.home_card_favorite + homeCardRegular?.nextFocusDownId = R.id.btn_bottom_guess + homeCardFavorite?.nextFocusUpId = R.id.btn_top_search + homeCardFavorite?.nextFocusLeftId = R.id.home_card_regular + homeCardFavorite?.nextFocusRightId = R.id.home_card_category + homeCardFavorite?.nextFocusDownId = R.id.btn_bottom_guess + homeCardCategory?.nextFocusUpId = R.id.home_card_rank + homeCardCategory?.nextFocusLeftId = R.id.home_card_favorite + homeCardCategory?.nextFocusRightId = R.id.home_card_local + homeCardCategory?.nextFocusDownId = R.id.btn_bottom_settings + homePosterView?.nextFocusUpId = R.id.btn_top_tone + homePosterView?.nextFocusLeftId = R.id.home_card_song + homePosterView?.nextFocusDownId = R.id.btn_bottom_exit + + btnBottomGuess?.nextFocusUpId = R.id.home_card_regular + btnBottomGuess?.nextFocusRightId = R.id.btn_bottom_mobile_order + btnBottomMobileOrder?.nextFocusUpId = R.id.home_card_local + btnBottomMobileOrder?.nextFocusLeftId = R.id.btn_bottom_guess + btnBottomMobileOrder?.nextFocusRightId = R.id.btn_bottom_settings + btnBottomSettings?.nextFocusUpId = R.id.home_card_local + btnBottomSettings?.nextFocusLeftId = R.id.btn_bottom_mobile_order + btnBottomSettings?.nextFocusRightId = R.id.btn_bottom_exit + btnBottomExit?.nextFocusUpId = R.id.home_poster_container + btnBottomExit?.nextFocusLeftId = R.id.btn_bottom_settings + } + + private fun clearHomeFocusTransition() { + lastHomeFocusSource = null + lastHomeFocusTarget = null + lastHomeFocusReverseKey = KeyEvent.KEYCODE_UNKNOWN + } + + private fun oppositeDpadKey(code: Int): Int = when (code) { + KeyEvent.KEYCODE_DPAD_LEFT -> KeyEvent.KEYCODE_DPAD_RIGHT + KeyEvent.KEYCODE_DPAD_RIGHT -> KeyEvent.KEYCODE_DPAD_LEFT + KeyEvent.KEYCODE_DPAD_UP -> KeyEvent.KEYCODE_DPAD_DOWN + KeyEvent.KEYCODE_DPAD_DOWN -> KeyEvent.KEYCODE_DPAD_UP + else -> KeyEvent.KEYCODE_UNKNOWN + } + + private fun clearHomeTopFocusNavigation() { + listOfNotNull( + btnTopSearch, btnTopOrder, btnTopVocal, btnTopNext, btnTopPause, btnTopReplay, btnTopTone, + ).forEach { it.nextFocusDownId = View.NO_ID } + } + + /** + * 更新 Tab 样式(选中/未选中) + */ + private fun updateTabStyle(tab: TextView, selected: Boolean) { + val normalText = if (selected) Color.rgb(253, 51, 89) else Color.argb(180, 241, 241, 241) + tab.setTextColor(ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_focused), + intArrayOf(android.R.attr.state_pressed), + intArrayOf(), + ), + intArrayOf(Color.WHITE, Color.WHITE, normalText), + )) + val normal = GradientDrawable().apply { + setColor(if (selected) Color.argb(30, 253, 51, 89) else Color.TRANSPARENT) + cornerRadius = dp(4).toFloat() + } + val focused = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(Color.rgb(232, 49, 111), Color.rgb(255, 154, 48)), + ).apply { + cornerRadius = dp(4).toFloat() + setStroke(dp(1), Color.argb(150, 255, 255, 255)) + } + tab.background = StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), focused) + addState(intArrayOf(android.R.attr.state_pressed), focused) + addState(intArrayOf(), normal) + } + } + + /** + * 构建虚拟键盘 (拼音首字母 + 数字 + 功能键) + * 4 行字母 + 1 行数字 = 完整 26 字母键盘 + */ + private fun buildKeyboard() { + val grid = findViewById(R.id.keyboard_grid) + if (grid == null) return + renderKeyboard("abc") + // 绑定输入模式切换 + val keyAbc = findViewById(R.id.key_abc) + val key123 = findViewById(R.id.key_123) + val keySpace = findViewById(R.id.key_space) + val keyBackspace = findViewById(R.id.key_backspace) + val keyClear = findViewById(R.id.key_clear) + if (keyAbc != null) keyAbc.setOnClickListener(View.OnClickListener { v: View? -> + hideSystemKeyboard() + keyAbc.setBackgroundResource(R.drawable.bg_key_mode) + keyAbc.setTextColor(Color.rgb(0x27, 0xCC, 0xA4)) + if (key123 != null) { + key123.setBackgroundResource(R.drawable.bg_key_mode_dim) + key123.setTextColor(Color.argb(153, 255, 255, 255)) + } + renderKeyboard("abc") + }) + if (key123 != null) key123.setOnClickListener(View.OnClickListener { v: View? -> + showSystemKeyboard() + }) + if (keySpace != null) keySpace.setOnClickListener(View.OnClickListener { v: View? -> + showSystemKeyboard() + }) + if (keyBackspace != null) keyBackspace.setOnClickListener(View.OnClickListener { v: View? -> + if (searchInput == null) return@OnClickListener + val s = searchInput!!.getText().toString() + if (!s.isEmpty()) { + searchInput!!.setText(s.substring(0, s.length - 1)) + searchInput!!.setSelection(searchInput!!.getText().length) + scheduleKeyboardSearch() + } + }) + if (keyClear != null) keyClear.setOnClickListener(View.OnClickListener { v: View? -> + if (searchInput != null) { + searchInput!!.setText("") + scheduleKeyboardSearch() + } + }) + } + + /** + * 切换键盘模式(ABC/123)并重建键盘 + * @param mode "abc" 或 "123" + */ + private var keyboardMode: String? = "abc" + private fun renderKeyboard(mode: String?) { + keyboardMode = mode + val grid = findViewById(R.id.keyboard_grid) + if (grid == null) return + grid.removeAllViews() + val rows: Array?>? + if ("123" == mode) { + rows = arrayOf?>( + arrayOf("1", "2", "3", "4", "5", "6", "7"), + arrayOf("8", "9", "0", "", "", "", ""), + arrayOf("", "", "", "", "", "", ""), + arrayOf("ABC", "", "", "", "", "", ""), + ) + } else { + rows = arrayOf?>( + arrayOf("A", "B", "C", "D", "E", "F", "G"), + arrayOf("H", "I", "J", "K", "L", "M", "N"), + arrayOf("O", "P", "Q", "R", "S", "T", "U"), + arrayOf("V", "W", "X", "Y", "Z", "123", ""), + ) + } + for (row in rows) { + val rowLayout = LinearLayout(this) + rowLayout.setOrientation(LinearLayout.HORIZONTAL) + rowLayout.setGravity(Gravity.CENTER) + rowLayout.setWeightSum(row!!.size.toFloat()) + val rowLp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f + ) + rowLp.bottomMargin = dp(2) + grid.addView(rowLayout, rowLp) + for (key in row) { + val btn = TextView(this) + btn.setText(key) + btn.setTextSize(15f) + btn.setTextColor(Color.rgb(241, 241, 241)) + btn.setGravity(Gravity.CENTER) + btn.setBackgroundColor(Color.TRANSPARENT) + val lp = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f) + lp.leftMargin = 2 + lp.rightMargin = 2 + btn.setLayoutParams(lp) + btn.visibility = if (key.isNullOrEmpty()) View.INVISIBLE else View.VISIBLE + btn.setOnClickListener { + when (key) { + "123" -> renderKeyboard("123") + "ABC" -> renderKeyboard("abc") + else -> appendToSearch(key) + } + } + btn.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> btn.setBackgroundResource(R.drawable.bg_key_char_pressed) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> btn.setBackgroundColor(Color.TRANSPARENT) + } + false + } + rowLayout.addView(btn) + } + } + } + + /** + * 向搜索框追加文本 + */ + private fun appendToSearch(key: String?) { + if (searchInput == null) return + val s = searchInput!!.getText().toString() + searchInput!!.setText(s + key) + searchInput!!.setSelection(searchInput!!.getText().length) + scheduleKeyboardSearch() + } + + private fun scheduleKeyboardSearch() { + main.removeCallbacks(keyboardSearchRunnable) + main.postDelayed(keyboardSearchRunnable, 350L) + } + + private fun setSearchText(value: String) { + val input = searchInput ?: return + main.removeCallbacks(keyboardSearchRunnable) + suppressSearchWatcher = true + input.setText(value) + input.setSelection(input.text.length) + suppressSearchWatcher = false + } + + private fun clearSearchForFilterChange() { + activeSearchQuery = "" + setSearchText("") + } + + private fun showSystemKeyboard() { + val input = searchInput ?: return + input.inputType = InputType.TYPE_CLASS_TEXT + input.requestFocus() + (getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager) + ?.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT) + } + + private fun hideSystemKeyboard() { + val input = searchInput ?: return + (getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager) + ?.hideSoftInputFromWindow(input.windowToken, 0) + } + + /** + * 加载热门歌曲列表 + */ + private fun loadHotSongs() { + searchScope = "none" + activeSearchQuery = "" + setSearchText("") + currentTabIndex = 9 + showSubPageShell("主页 / 排行榜") + setupTabs() + if (keyboardArea != null) keyboardArea!!.setVisibility(View.GONE) + rankCategoryGrid?.visibility = View.VISIBLE + listTitle!!.setText("排行榜") + browseMode = "rank" + browseParam = "" + browsePage = 0 + currentCategories.clear() + rankPlaylistIds.clear() + currentCategoryIndex = 0 + updateCategories() + if (!library.muse.isAvailable()) { + catalogRefreshPending = true + busy(true, "曲库加载中...") + return + } + io.execute { + val playlists = library.muse.rankPlaylists() + main.post { + if (currentTabIndex != 9 || browseMode != "rank") return@post + currentCategories.clear() + rankPlaylistIds.clear() + playlists.forEach { playlist -> + rankPlaylistIds.add(playlist[0].orEmpty()) + currentCategories.add(playlist[1].orEmpty()) + } + currentCategoryIndex = playlists.indexOfFirst { + it[2] == "2" || it[1] == "热歌榜" + }.takeIf { it >= 0 } ?: 0 + browseParam = rankPlaylistIds.getOrNull(currentCategoryIndex).orEmpty() + listTitle!!.text = currentCategories.getOrNull(currentCategoryIndex) ?: "排行榜" + updateRankCategoryGrid() + subCategoryScroll?.visibility = View.GONE + loadBrowsePage() + } + } + } + + private fun updateRankCategoryGrid() { + val grid = rankCategoryGrid ?: return + grid.removeAllViews() + currentCategories.take(9).forEachIndexed { index, title -> + val tile = TextView(this).apply { + text = title + textSize = 14f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + isClickable = true + isFocusable = true + setBackgroundResource( + if (index == currentCategoryIndex) R.drawable.ott_bg_rank_item_selected + else R.drawable.ott_bg_rank_item_not_selected + ) + val trophy = getDrawable(R.drawable.ic_default_rank_avatar)?.mutate() + trophy?.setBounds(0, 0, dp(42), dp(42)) + setCompoundDrawables(null, trophy, null, null) + compoundDrawablePadding = dp(1) + setOnClickListener { + if (index == currentCategoryIndex) return@setOnClickListener + currentCategoryIndex = index + browseMode = "rank" + browseParam = rankPlaylistIds.getOrNull(index).orEmpty() + browsePage = 0 + listTitle?.text = title + updateRankCategoryGrid() + loadBrowsePage() + } + } + installPressFeedback(tile) + grid.addView(tile, GridLayout.LayoutParams().apply { + rowSpec = GridLayout.spec(index / 3, 1f) + columnSpec = GridLayout.spec(index % 3, 1f) + width = 0 + height = 0 + setMargins(dp(3), dp(3), dp(3), dp(3)) + }) + } + } + + private fun loadCategoryPlaylists() { + searchScope = "playlist" + activeSearchQuery = "" + setSearchText("") + currentTabIndex = 2 + showSubPageShell("主页 / 分类") + setupTabs() + currentCategories.clear() + currentCategoryIndex = -1 + updateCategories() + browseMode = "category_list" + browseParam = "" + browsePage = 0 + loadCategoryPlaylistPage("") + } + + private fun returnToCategoryPlaylists() { + searchScope = "playlist" + activeSearchQuery = categoryParentQuery + setSearchText(categoryParentQuery) + currentTabIndex = 2 + showSubPageShell("主页 / 分类") + setupTabs() + currentCategories.clear() + currentCategoryIndex = -1 + updateCategories() + browseMode = "category_list" + browseParam = categoryParentQuery + browsePage = categoryParentPage + loadCategoryPlaylistPage(categoryParentQuery) + } + + private fun loadCategoryPlaylistPage(query: String = activeSearchQuery) { + browseMode = "category_list" + browseParam = query + val requestVersion = ++browseRequestVersion + val requestedPage = browsePage + val requestedQuery = query.trim() + val pageSize = 12 + io.execute { + val playlists = library.muse.categoryPlaylists(requestedQuery, requestedPage * pageSize, pageSize) + val total = library.muse.categoryPlaylistCount(requestedQuery) + main.post { + if (requestVersion != browseRequestVersion || browseMode != "category_list" || + browsePage != requestedPage || activeSearchQuery != requestedQuery + ) return@post + browseTotalCount = total + browseTotalPages = (total + pageSize - 1) / pageSize + visiblePlaylists.clear() + visiblePlaylists.addAll(playlists) + songList?.apply { + dividerHeight = 0 + adapter = PlaylistGridAdapter(this@MainActivity, playlists) { playlist -> + openCategoryPlaylist(playlist) + } + } + updatePageInfo() + } + } + } + + private fun openCategoryPlaylist(playlist: Array) { + categoryParentPage = browsePage + categoryParentQuery = activeSearchQuery + clearSearchForFilterChange() + showSubPageShell("主页 / 分类 / ${playlist.getOrNull(1).orEmpty()}") + currentTabIndex = 2 + setupTabs() + searchScope = "playlist_songs" + searchContextId = playlist.getOrNull(0).orEmpty() + currentCategories.clear() + currentCategoryIndex = -1 + updateCategories() + browseMode = "playlist_id" + browseParam = playlist.getOrNull(0).orEmpty() + browsePage = 0 + loadBrowsePage() + } + + private fun loadQuickSearchPage(query: String = activeSearchQuery) { + browseMode = "quick" + browseParam = query + val requestVersion = ++browseRequestVersion + val requestedPage = browsePage + val requestedQuery = query.trim() + io.execute { + val songs = if (requestedQuery.isEmpty()) { + library.muse.hotSongs(requestedPage * 6, 6) + } else { + library.muse.searchSongs(requestedQuery, requestedPage * 6, 6) + } + val singers = if (requestedQuery.isEmpty()) { + library.muse.singers("", "", requestedPage * 4, 4) + } else { + library.muse.searchSingers(requestedQuery, "", "", requestedPage * 4, 4) + } + val total = if (requestedQuery.isEmpty()) { + library.muse.songCount() + } else { + library.muse.searchSongCount(requestedQuery) + } + main.post { + if (requestVersion != browseRequestVersion || browseMode != "quick" || + browsePage != requestedPage || activeSearchQuery != requestedQuery + ) return@post + browseTotalCount = total + browseTotalPages = (total + 5) / 6 + visibleSongs.clear() + visibleSongs.addAll(songs) + visibleSingers.clear() + visibleSingers.addAll(singers) + songList?.apply { + dividerHeight = 0 + adapter = QuickSearchAdapter( + this@MainActivity, + singers, + songs, + onSingerClick = { singer -> loadSingerSongs(singer.getOrNull(0), singer.getOrNull(1)) }, + onSongClick = { song -> addToQueue(song) }, + onSongMore = { song -> showSongActions(song) }, + ) + } + updatePageInfo() + } + } + } + + /** + * 加载语种列表 + */ + private fun loadLanguages() { + currentTabIndex = 2 + showSubPageShell("主页 / 语种") + setupTabs() + listTitle!!.setText("语种点歌") + if (library.muse.isAvailable()) { + currentCategories.clear() + currentCategories.addAll(library.muse.languages()) + currentCategoryIndex = 0 + updateCategories() + if (!currentCategories.isEmpty()) { + browseMode = "language" + browseParam = currentCategories.get(0) + browsePage = 0 + loadBrowsePage() + } + } + } + + /** + * 加载字数分类 + */ + private fun loadWordCounts() { + currentTabIndex = 3 + showSubPageShell("主页 / 字数") + setupTabs() + listTitle!!.setText("字数点歌") + currentCategories.clear() + currentCategories.add("2字") + currentCategories.add("3字") + currentCategories.add("4字") + currentCategories.add("5字") + currentCategories.add("6字") + currentCategories.add("7字以上") + currentCategoryIndex = 0 + updateCategories() + browseMode = "wordcount" + browseParam = "4" // 默认显示4字歌曲 + browsePage = 0 + loadBrowsePage() + } + + /** + * 加载歌手列表 + */ + private fun loadSingers() { + searchScope = "singer" + activeSearchQuery = "" + setSearchText("") + currentTabIndex = 1 + showSubPageShell("主页 / 歌星") + setupTabs() + listTitle!!.setText("歌星点歌") + currentCategories.clear() + currentCategories.add("全部") + currentCategories.add("大陆男") + currentCategories.add("大陆女") + currentCategories.add("港台男") + currentCategories.add("港台女") + currentCategories.add("中国组合") + currentCategories.add("外国组合") + currentCategoryIndex = 0 + updateCategories() + browsePage = 0 + loadSingerList("全部") + } + + private fun loadSingerList(type: String?, query: String = activeSearchQuery) { + browseMode = "singer_list" + browseParam = type + if (!library.muse.isAvailable()) { + catalogRefreshPending = true + busy(true, "曲库加载中...") + return + } + val requestVersion = ++browseRequestVersion + val requestedPage = browsePage + val requestedType = type.orEmpty() + val requestedQuery = query.trim() + val pageSize = 8 + val offset = requestedPage * pageSize + val (areaFilter, typeFilter) = singerFilter(type) + io.execute(Runnable { + val singers: MutableList> = if (requestedQuery.isEmpty()) { + library.muse.singers(areaFilter, typeFilter, offset, pageSize) + } else { + library.muse.searchSingers(requestedQuery, areaFilter, typeFilter, offset, pageSize) + } + val total = if (requestedQuery.isEmpty()) { + library.muse.singerCount(areaFilter, typeFilter) + } else { + library.muse.searchSingerCount(requestedQuery, areaFilter, typeFilter) + } + main.post(Runnable { + if (requestVersion != browseRequestVersion || browseMode != "singer_list" || + browseParam.orEmpty() != requestedType || browsePage != requestedPage || + activeSearchQuery != requestedQuery + ) return@Runnable + browseTotalCount = total + browseTotalPages = (total + pageSize - 1) / pageSize + visibleSingers.clear() + visibleSingers.addAll(singers) + songList!!.dividerHeight = 0 + songList!!.adapter = SingerGridAdapter(this, singers) { singer -> + loadSingerSongs(singer.getOrNull(0), singer.getOrNull(1)) + } + updatePageInfo() + }) + }) + } + + private fun singerFilter(category: String?): Pair = when (category) { + "大陆男" -> "大陆" to "男" + "大陆女" -> "大陆" to "女" + "港台男" -> "港台" to "男" + "港台女" -> "港台" to "女" + "中国组合" -> "中国" to "组合" + "外国组合" -> "外国" to "组合" + else -> "" to "" + } + + private fun loadSingerSongs(singerId: String?, singerName: String?) { + searchScope = "singer_songs" + searchContextId = singerId.orEmpty() + activeSearchQuery = "" + setSearchText("") + showSubPageShell("主页 / 歌星 / " + singerName) + currentTabIndex = 1 + setupTabs() + browseMode = "singer_id" + browseParam = singerId + browsePage = 0 + listTitle!!.setText(singerName) + loadBrowsePage() + } + + /** + * 加载收藏歌曲 + */ + /** + * 加载歌单 + */ + private fun loadPlaylists() { + showSubPageShell("主页 / 歌单") + listTitle!!.setText("我的歌单") + currentCategories.clear() + // 获取歌单列表(从数据库 playlists 表) + val playlistData: MutableList> = library.muse.playlists(0, 100) + if (playlistData.isEmpty()) { + currentCategories.add("暂无歌单") + } else { + for (p in playlistData) { + currentCategories.add(p[1].orEmpty()) // p[1] 是歌单名称 + } + } + currentCategoryIndex = 0 + updateCategories() + if (!currentCategories.isEmpty() && currentCategories.get(0) != "暂无歌单") { + browseMode = "playlist" + browseParam = currentCategories.get(0) + browsePage = 0 + loadBrowsePage() + } + } + + /** + * 加载已点歌曲列表 (Tab 4) + */ + private fun loadOrderedList() { + currentTabIndex = 4 + showSubPageShell("主页 / 已点") + setupTabs() + if (keyboardArea != null) keyboardArea!!.setVisibility(View.GONE) + listTitle!!.setText("已点歌曲 (" + orderQueue!!.size + ")") + currentCategories.clear() + currentCategoryIndex = 0 + browseMode = "ordered" + browseParam = "" + browsePage = 0 + // 直接更新歌曲列表 + visibleSongs.clear() + visibleSongs.addAll(orderQueue) + updateCategories() + renderSongList() + updateOrderBadge() + } + + /** + * 加载下载列表 (Tab 5) + */ + private fun loadDownloadedList() { + currentTabIndex = 6 + showSubPageShell("主页 / 下载") + setupTabs() + listTitle!!.setText("下载管理") + currentCategories.clear() + currentCategoryIndex = 0 + browseMode = "downloads" + browseParam = "" + browsePage = 0 + // The original download page is a task queue. Completed files move to the local-song page. + visibleSongs.clear() + for (task in downloads) { + visibleSongs.add(task.song) + } + if (visibleSongs.isEmpty()) { + toast("暂无下载任务") + } + updateCategories() + renderSongList() + } + + private fun loadLocalCatalogPage() { + browseMode = "local" + val language = searchLanguage.ifEmpty { browseParam.orEmpty() } + val query = activeSearchQuery.trim().uppercase(Locale.ROOT) + val requestedPage = browsePage + val requestVersion = ++browseRequestVersion + val pageSize = MuseDatabase.PAGE_SIZE + io.execute { + val localSongs = localSongInventory() + downloads.asSequence().map { it.song } + .plus(library.allSongs().asSequence()) + .forEach { song -> + if (song.hasLocalFile() || isDownloaded(song)) { + localSongs.putIfAbsent(stableId(song), song) + } + } + val filtered = localSongs.values.filter { + val languageMatches = language.isEmpty() || language == "全部" || it.language == language + val text = listOf(it.title, it.singer, it.pinyin, it.pinyinFull) + .joinToString(" ").uppercase(Locale.ROOT) + languageMatches && (query.isEmpty() || query in text) + } + val from = (requestedPage * pageSize).coerceAtMost(filtered.size) + val page = filtered.drop(from).take(pageSize) + main.post { + if (requestVersion != browseRequestVersion || browseMode != "local" || + browsePage != requestedPage || activeSearchQuery.uppercase(Locale.ROOT) != query || + searchLanguage != language + ) return@post + browseTotalCount = filtered.size + browseTotalPages = (filtered.size + pageSize - 1) / pageSize + visibleSongs.clear() + visibleSongs.addAll(page) + renderSongList() + updatePageInfo() + } + } + } + + /** + * 加载已唱历史 (Tab 5) + */ + private fun loadSangHistory() { + currentTabIndex = 5 + showSubPageShell("主页 / 已唱") + setupTabs() + if (keyboardArea != null) keyboardArea!!.setVisibility(View.GONE) + listTitle!!.setText("已唱历史 (" + sangHistory.size + ")") + currentCategories.clear() + currentCategoryIndex = 0 + browseMode = "sang" + browseParam = "" + browsePage = 0 + visibleSongs.clear() + visibleSongs.addAll(sangHistory) + updateCategories() + renderSongList() + } + + /** + * 加载收藏列表 (Tab 7) - 从 KtvStore 收藏和数据库查询 + */ + private fun loadFavorites() { + currentTabIndex = 7 + showSubPageShell("主页 / 收藏") + setupTabs() + listTitle!!.setText("我的收藏") + currentCategories.clear() + currentCategoryIndex = 0 + browseMode = "favorites" + browseParam = "" + browsePage = 0 + visibleSongs.clear() + if (!library.muse.isAvailable()) { + catalogRefreshPending = true + busy(true, "曲库加载中...") + return + } + // 从数据库查询收藏歌曲 + io.execute(Runnable { + val result: MutableList = ArrayList() + for (songId in store.favoriteIds) { + val song = library.muse.songById(songId) + ?: library.allSongs().firstOrNull { stableId(it) == songId } + if (song != null) result.add(song) + } + main.post(Runnable { + visibleSongs.clear() + visibleSongs.addAll(result) + updateCategories() + renderSongList() + if (visibleSongs.isEmpty()) toast("暂无收藏,长按歌曲可收藏") + }) + }) + } + + /** + * 显示设置对话框 (旧版 Tab 8, 现已改为弹窗) + */ + private fun showSettingsPage(requestInitialFocus: Boolean = true) { + currentTabIndex = 8 + showSubPageShell("主页 / 设置") + browseMode = "settings" + visibleSongs.clear() + currentCategories.clear() + updateCategories() + if (keyboardArea != null) keyboardArea!!.setVisibility(View.GONE) + if (tabContainer != null) tabContainer!!.setVisibility(View.GONE) + if (subCategoryScroll != null) subCategoryScroll!!.setVisibility(View.GONE) + if (paginationBar != null) paginationBar!!.setVisibility(View.GONE) + browsePagination?.visibility = View.GONE + + val entries = listOf( + SettingsEntry(R.drawable.ott_ic_data_setting, "数据设置", "数据库、歌曲下载、歌曲文件相关设置") { showSettingsSection(1) }, + SettingsEntry(R.drawable.ott_ic_play, "播放设置", "播放器、视频窗口相关设置") { showSettingsSection(2) }, + SettingsEntry(R.drawable.ott_ic_sound, "声音设置") { showSoundSettingsDialog() }, + SettingsEntry( + R.drawable.ott_ic_data_upgrade, + "应用更新", + "当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitee Release", + "检查更新", + ) { checkAppUpdate(true) }, + SettingsEntry(R.drawable.ott_ic_about_us, "关于", actionText = "查看") { showAboutDialog() }, + ) + activeSettingsEntries = entries + songList!!.setDivider(ColorDrawable(Color.TRANSPARENT)) + songList!!.setDividerHeight(dp(12)) + songList!!.setBackgroundColor(Color.TRANSPARENT) + val settingsAdapter = SettingsListAdapter(this, entries, R.id.btn_back) + songList!!.adapter = settingsAdapter + btnBack?.nextFocusDownId = settingsAdapter.firstActionId + if (requestInitialFocus) settingsAdapter.requestInitialFocus(songList!!) + } + + private fun openSettingsItem(position: Int) { + activeSettingsEntries.getOrNull(position)?.action?.invoke() + } + + private fun showSettingsSection(section: Int, requestInitialFocus: Boolean = true) { + val names = arrayOf("网络设置", "数据设置", "播放设置", "声音设置", "界面设置") + currentTabIndex = 8 + showSubPageShell("主页 / 设置 / ${names[section]}") + browseMode = "settings_section" + currentCategories.clear() + updateCategories() + keyboardArea?.visibility = View.GONE + tabContainer?.visibility = View.GONE + browsePagination?.visibility = View.GONE + val entries = when (section) { + 0 -> listOf( + SettingsEntry(R.drawable.ott_ic_wifi_setting, "无线网络") { + runCatching { startActivity(Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)) } + }, + ) + 1 -> listOf( + SettingsEntry(R.drawable.ott_ic_data_upgrade, "曲库", "数据库 ${library.muse.songCount()} 首 本地已下载 ${countDownloadedFiles()} 首", "更新") { + io.execute { library.muse.close(); library.muse.open(); main.post { showSettingsSection(1, false) } } + }, + SettingsEntry(R.drawable.ott_ic_data_setting, "U盘加歌", "扫描U盘内按规定命名的歌曲文件,并添加到曲库中", "立即加歌") { toast("未检测到U盘") }, + SettingsEntry(R.drawable.ott_ic_setting_storage_space, "预留存储空间", "当前 ${String.format(Locale.ROOT, "%.1f", reserveStorageGb)} GB") { showReserveStorageDialog() }, + SettingsEntry(R.drawable.ott_ic_data_reset, "自动删歌", "当剩余存储空间低于预留空间时,自动删除最久未使用的歌曲", action = { + autoDeleteSongs = !autoDeleteSongs + saveState() + if (autoDeleteSongs) enforceStorageReserve(showResult = true) + }, checked = autoDeleteSongs), + SettingsEntry(R.drawable.ott_ic_data_upgrade, "重置数据库", "删除本地数据库并重新下载", "重置") { confirmResetDatabase() }, + SettingsEntry(R.drawable.ott_ic_setting_storage_space, "硬盘读写权限") { toast(storageStatusText()) }, + ) + 2 -> listOf( + SettingsEntry(R.drawable.ott_ic_download_setting, "开机清空下载列表", "开机时删除本地已下载歌曲并清理数据库状态", action = { clearDownloadsOnBoot = !clearDownloadsOnBoot; saveState() }, checked = clearDownloadsOnBoot), + SettingsEntry(R.drawable.ott_ic_tv_display_mode, "自动全屏", "设置多久未操作后自动进入全屏", "设置间隔") { showAutoFullscreenDialog() }, + SettingsEntry(R.drawable.ott_ic_play, "音画同步") { showAudioSyncDialog() }, + SettingsEntry(R.drawable.ott_ic_marquee_setting, "歌曲片头字幕", action = { songTitleSubtitleEnabled = !songTitleSubtitleEnabled; saveState() }, checked = songTitleSubtitleEnabled), + ) + 3 -> listOf( + SettingsEntry(R.drawable.ott_ic_sound, "声音设置") { showSoundSettingsDialog() }, + ) + else -> emptyList() + } + activeSettingsEntries = entries + songList!!.divider = ColorDrawable(Color.TRANSPARENT) + songList!!.dividerHeight = dp(12) + songList!!.setBackgroundColor(Color.TRANSPARENT) + val settingsAdapter = SettingsListAdapter(this, entries, R.id.btn_back) + songList!!.adapter = settingsAdapter + btnBack?.nextFocusDownId = settingsAdapter.firstActionId + if (requestInitialFocus) settingsAdapter.requestInitialFocus(songList!!) + } + + private fun isNetworkConnected(): Boolean { + val manager = getSystemService(CONNECTIVITY_SERVICE) as? ConnectivityManager + return manager?.activeNetworkInfo?.isConnected == true + } + + private fun networkStatusText(): String = if (isNetworkConnected()) "已连接" else "未连接" + + private fun storageStatusText(): String { + val root = Environment.getExternalStorageDirectory() + return "可用 ${formatSize(root.usableSpace)} / 总计 ${formatSize(root.totalSpace)}" + } + + private fun downloadedSongDirectory(): File = + File(MuseDatabase.VIDEO_ROOT, MuseDatabase.CLOUD_SONG_DIR) + + private fun countDownloadedFiles(): Int = localSongInventory().size + + private fun localSongInventory(): LinkedHashMap { + val result = LinkedHashMap() + library.muse.localPathSongs().forEach { song -> + val resolved = File(MuseDatabase.resolveSongFilePath(song)) + if (SongFileValidator.inspect( + resolved, + SongFileValidator.requiresTransportStream(resolved), + ).valid + ) { + song.path = resolved.absolutePath + result.putIfAbsent(stableId(song), song) + } + } + + val downloadedFiles = MuseDatabase.songDirectories() + .flatMap { it.listFiles().orEmpty().asIterable() } + .distinctBy { it.name } + val inspections = downloadedFiles.asSequence() + .filter { it.isFile && !it.name.endsWith(".download") } + .associateWith { file -> + SongFileValidator.inspect(file, SongFileValidator.requiresTransportStream(file)) + } + inspections.filter { (file, inspection) -> + file.parentFile?.absolutePath == downloadedSongDirectory().absolutePath && !inspection.valid + }.keys.forEach { invalid -> + Log.w(TAG, "Deleting invalid downloaded song: ${invalid.absolutePath}, ${inspections[invalid]?.reason}") + SongFileValidator.forget(invalid) + cleanupSidecars(invalid) + invalid.delete() + } + val validFiles = inspections.filter { (file, inspection) -> file.exists() && inspection.valid }.keys + val filesByName = validFiles.associateBy { it.name } + val databaseSongs = library.muse.songsByFilenames(filesByName.keys) + databaseSongs.forEach { song -> + filesByName[song.filename]?.let { song.path = it.absolutePath } + result.putIfAbsent(stableId(song), song) + } + val matchedNames = databaseSongs.mapNotNullTo(HashSet()) { it.filename } + validFiles.filter { it.name !in matchedNames }.forEach { file -> + val song = Song.local(file.absolutePath, file.name) + song.filename = file.name + result.putIfAbsent(stableId(song), song) + } + return result + } + + private fun clearDownloadedFilesOnBoot() { + downloadedSongDirectory().listFiles().orEmpty().forEach { file -> + if (file.isFile) { + cleanupSidecars(file) + runCatching { file.delete() } + } + } + downloads.clear() + runCatching { stateDatabase.clearDownloads() } + } + + private fun isBelowReservedStorage(): Boolean { + val required = (reserveStorageGb * 1024.0 * 1024.0 * 1024.0).toLong() + return downloadedSongDirectory().usableSpace < required + } + + private fun enforceStorageReserve(showResult: Boolean = false) { + if (!autoDeleteSongs || !isBelowReservedStorage()) return + io.execute { + val deleted = purgeColdDownloadedFiles() + val enough = !isBelowReservedStorage() + if (showResult) main.post { + toast( + if (enough) "已清理 $deleted 首歌曲,预留空间已恢复" + else "已清理 $deleted 首歌曲,受保护歌曲不会被删除,空间仍不足", + ) + if (browseMode == "settings_section") showSettingsSection(1, false) + } + } + } + + private fun purgeColdDownloadedFiles(): Int { + val required = (reserveStorageGb * 1024.0 * 1024.0 * 1024.0).toLong() + val protectedNames = buildSet { + currentSong?.filename?.let(::add) + orderQueue.orEmpty().mapNotNullTo(this) { it.filename } + } + var deletedCount = 0 + downloadedSongDirectory().listFiles().orEmpty() + .filter { it.isFile && it.name !in protectedNames && !it.name.endsWith(".download") } + .sortedWith(compareBy { it.lastModified() }.thenBy { it.name }) + .forEach { file -> + if (downloadedSongDirectory().usableSpace >= required) return deletedCount + cleanupSidecars(file) + if (file.delete()) { + deletedCount++ + library.allSongs().filter { it.filename == file.name }.forEach { song -> + song.path = null + runCatching { stateDatabase.removeDownload(song) } + } + } + } + return deletedCount + } + + private fun confirmResetDatabase() { + AlertDialog.Builder(this) + .setTitle("重置数据库") + .setMessage("将删除本地曲库数据库,然后重新从官方曲库同步下载。不会保留备份,是否继续?") + .setNegativeButton("取消", null) + .setPositiveButton("确认") { _, _ -> resetDatabaseFromMuse() } + .create().showForTv() + } + + private fun resetDatabaseFromMuse() { + showDatabaseLoading(true, "正在重置数据库...", null) + io.execute { + library.muse.close() + MuseDatabase.defaultDbFile().delete() + val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress) + val opened = result.isSuccess && library.muse.open() + main.post { + if (opened) { + showDatabaseLoading(false, "", null) + toast("数据库重置完成") + showSettingsSection(1, false) + } else { + showDatabaseLoadingFailure("数据库重置失败:${result.exceptionOrNull()?.message.orEmpty()}") + } + } + } + } + + private fun showDatabaseLoading(show: Boolean, message: String, progressValue: Int?) { + if (!show) { + databaseLoadingOverlay?.visibility = View.GONE + return + } + if (databaseLoadingOverlay == null) { + val root = findViewById(R.id.layout_root) + val overlay = FrameLayout(this).apply { + setBackgroundColor(Color.rgb(24, 17, 45)) + isClickable = true + isFocusable = true + elevation = dp(30).toFloat() + } + val center = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + addView(ImageView(this@MainActivity).apply { + setImageResource(R.drawable.ktv_logo) + scaleType = ImageView.ScaleType.CENTER_INSIDE + }, LinearLayout.LayoutParams(dp(112), dp(112))) + addView(ProgressBar(this@MainActivity, null, 0, R.style.LoadingProgressBar), LinearLayout.LayoutParams(dp(54), dp(54)).apply { + topMargin = dp(22) + }) + databaseLoadingText = TextView(this@MainActivity).apply { + textSize = 20f + gravity = Gravity.CENTER + setTextColor(Color.WHITE) + setPadding(dp(20), dp(18), dp(20), dp(12)) + } + addView(databaseLoadingText, LinearLayout.LayoutParams(dp(620), -2)) + databaseLoadingProgress = ProgressBar(this@MainActivity, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + progressDrawable = getDrawable(R.drawable.bg_download_progress) + } + addView(databaseLoadingProgress, LinearLayout.LayoutParams(dp(620), dp(10))) + databaseRetryButton = TextView(this@MainActivity).apply { + text = "重试" + textSize = 19f + gravity = Gravity.CENTER + setTextColor(Color.rgb(45, 20, 65)) + isClickable = true + isFocusable = true + visibility = View.GONE + background = GradientDrawable().apply { + cornerRadius = dp(12).toFloat() + setColor(Color.rgb(255, 204, 92)) + } + } + addView(databaseRetryButton, LinearLayout.LayoutParams(dp(180), dp(54)).apply { + gravity = Gravity.CENTER_HORIZONTAL + topMargin = dp(24) + }) + } + overlay.addView(center, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER)) + root.addView(overlay, FrameLayout.LayoutParams(-1, -1)) + databaseLoadingOverlay = overlay + } + databaseLoadingOverlay?.visibility = View.VISIBLE + databaseLoadingText?.text = message + databaseLoadingProgress?.apply { + visibility = if (progressValue == null) View.INVISIBLE else View.VISIBLE + progress = progressValue ?: 0 + } + databaseRetryButton?.apply { + visibility = View.GONE + isEnabled = true + setOnClickListener(null) + } + databaseLoadingOverlay?.bringToFront() + } + + private fun showDatabaseLoadingFailure(message: String) { + showDatabaseLoading(true, "$message\n请检查网络或空间后重试", null) + databaseRetryButton?.apply { + visibility = View.VISIBLE + setOnClickListener { + isEnabled = false + initializeDatabase() + } + requestFocus() + } + } + + // ===== 设置子功能 (新Tab版) ===== + private fun dialogSingleChoiceGroup( + labels: Array, + selected: Int, + onSelected: (Int) -> Unit, + ): android.widget.RadioGroup = android.widget.RadioGroup(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(6), dp(20), dp(6)) + labels.forEachIndexed { index, label -> + addView(android.widget.RadioButton(this@MainActivity).apply { + id = View.generateViewId() + tag = index + text = label + textSize = 17f + setTextColor(Color.WHITE) + isChecked = index == selected + minimumHeight = 0 + minHeight = 0 + setPadding(dp(8), 0, dp(14), 0) + layoutParams = android.widget.RadioGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(48), + ) + TvFocusStyler.installAction(this) + }) + } + setOnCheckedChangeListener { group, checkedId -> + val index = group.findViewById(checkedId)?.tag as? Int + ?: return@setOnCheckedChangeListener + onSelected(index) + } + } + + private fun dialogMultiChoiceGroup( + labels: Array, + checked: BooleanArray, + ): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(6), dp(20), dp(6)) + labels.forEachIndexed { index, label -> + addView(android.widget.CheckBox(this@MainActivity).apply { + text = label + textSize = 17f + setTextColor(Color.WHITE) + isChecked = checked.getOrElse(index) { false } + minimumHeight = 0 + minHeight = 0 + setPadding(dp(8), 0, dp(14), 0) + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(48), + ) + setOnCheckedChangeListener { _, value -> checked[index] = value } + TvFocusStyler.installAction(this) + }) + } + } + + private fun showScreenDialog() { + val items = arrayOf("16 : 9", "4 : 3", "全屏") + val selected = when (screenMode) { + "16 : 9" -> 0 + "4 : 3" -> 1 + else -> 2 + } + var pending = selected + val choices = dialogSingleChoiceGroup(items, selected) { pending = it } + AlertDialog.Builder(this) + .setTitle("视频比例") + .setView(choices) + .setNegativeButton("关闭", null) + .setPositiveButton("确认") { _, _ -> + screenMode = items[pending] + saveState() + applyScreenMode() + }.create().showForTv() + } + + private fun showAutoFullscreenDialog() { + val items = arrayOf("30秒", "45秒", "1分钟", "2分钟", "关闭(不推荐)") + val seconds = intArrayOf(30, 45, 60, 120, 0) + val selected = seconds.indexOf(autoFullscreenSeconds).let { if (it >= 0) it else 4 } + var pending = selected + val choices = dialogSingleChoiceGroup(items, selected) { pending = it } + AlertDialog.Builder(this).setTitle("设置间隔") + .setView(choices) + .setNegativeButton("关闭", null) + .setPositiveButton("确认") { _, _ -> + autoFullscreenSeconds = seconds[pending] + saveState() + scheduleAutoFullscreen() + showSettingsSection(2, false) + }.create().showForTv() + } + + private fun showReserveStorageDialog() { + val totalGb = downloadedSongDirectory().totalSpace.toDouble() / 1024.0 / 1024.0 / 1024.0 + val values = listOf(0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0) + .filter { it <= max(0.5, totalGb * 0.95) } + .ifEmpty { listOf(0.5) } + val labels = values.map { value -> + "${String.format(Locale.ROOT, if (value < 1) "%.1f" else "%.0f", value)} GB" + }.toTypedArray() + var selected = values.indices.minByOrNull { kotlin.math.abs(values[it] - reserveStorageGb) } ?: 0 + AlertDialog.Builder(this).setTitle("预留存储空间") + .setSingleChoiceItems(labels, selected) { _, which -> selected = which } + .setNegativeButton("取消", null).setPositiveButton("保存") { _, _ -> + reserveStorageGb = values[selected] + saveState() + if (autoDeleteSongs) enforceStorageReserve(showResult = true) + showSettingsSection(1, false) + }.create().showForTv() + } + + private fun showAudioSyncDialog() { + var pendingDelay = audioDelayMs + val seek = SeekBar(this).apply { max = 40; progress = (pendingDelay / 50 + 20).coerceIn(0, 40) } + val value = TextView(this).apply { gravity = Gravity.CENTER; textSize = 18f } + fun update() { value.text = "声音${if (pendingDelay >= 0) "提前" else "延后"} ${kotlin.math.abs(pendingDelay) / 1000f} 秒" } + update() + seek.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) { pendingDelay = (progress - 20) * 50; update() } + override fun onStartTrackingTouch(bar: SeekBar?) = Unit + override fun onStopTrackingTouch(bar: SeekBar?) = Unit + }) + val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(32), dp(8), dp(32), dp(16)); addView(value); addView(seek) } + AlertDialog.Builder(this).setTitle("音画同步").setView(box).setMessage("重唱或切歌后生效") + .setNegativeButton("关闭", null) + .setPositiveButton("确认") { _, _ -> audioDelayMs = pendingDelay; saveState() } + .create().showForTv() + } + + private fun showBannerConfigDialog() { + val labels = arrayOf("新歌", "戏曲", "广场舞") + val pending = booleanArrayOf(true, true, true) + val choices = dialogMultiChoiceGroup(labels, pending) + AlertDialog.Builder(this).setTitle("轮播图配置") + .setView(choices) + .setNegativeButton("关闭", null).setPositiveButton("确认", null) + .create().showForTv() + } + + private fun showSoundSettingsDialog() { + var pendingOrderedSongOriginal = orderedSongOriginal + var pendingOrderedVolumeMode = orderedVolumeMode + var pendingOrderedVocalMode = orderedVocalMode + var pendingMusicVolume = musicVolume + fun title(text: String) = TextView(this).apply { + this.text = text; textSize = 16f; setTextColor(Color.WHITE); setPadding(0, dp(4), 0, 0) + } + fun choices(labels: Array, selected: Int, horizontal: Boolean = false, changed: (Int) -> Unit) = + android.widget.RadioGroup(this).apply { + orientation = if (horizontal) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL + labels.forEachIndexed { index, label -> + addView(android.widget.RadioButton(this@MainActivity).apply { + text = label; textSize = 15f; setTextColor(Color.WHITE); isChecked = index == selected + id = View.generateViewId(); tag = index + minimumHeight = 0 + minHeight = 0 + setPadding(0, 0, dp(6), 0) + layoutParams = android.widget.RadioGroup.LayoutParams(-2, dp(30)) + TvFocusStyler.installAction(this) + }) + } + setOnCheckedChangeListener { group, checkedId -> + val index = group.findViewById(checkedId)?.tag as? Int ?: return@setOnCheckedChangeListener + changed(index) + } + } + val musicVolumeTitle = title("歌曲最大音量 ${pendingMusicVolume}%") + val left = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(title("切到点播歌曲时音量:")) + addView(choices(arrayOf("跟随上首音量", "跟随上首点播歌曲音量"), pendingOrderedVolumeMode) { pendingOrderedVolumeMode = it }) + addView(musicVolumeTitle) + addView(SeekBar(this@MainActivity).apply { + max = 100; progress = pendingMusicVolume + setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(bar: SeekBar?, value: Int, fromUser: Boolean) { + pendingMusicVolume = value + musicVolumeTitle.text = "歌曲最大音量 ${pendingMusicVolume}%" + } + override fun onStartTrackingTouch(bar: SeekBar?) = Unit + override fun onStopTrackingTouch(bar: SeekBar?) = Unit + }) + }) + } + val right = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(title("点播歌曲原伴唱")) + addView(choices(arrayOf("原唱", "伴唱"), if (pendingOrderedSongOriginal) 0 else 1, true) { pendingOrderedSongOriginal = it == 0 }) + addView(title("切到点播歌曲时原伴唱:")) + addView(choices(arrayOf("跟随上首原伴唱", "跟随上首点播原伴唱", "固定点播原伴唱"), pendingOrderedVocalMode) { pendingOrderedVocalMode = it }) + } + val content = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL; setPadding(dp(28), dp(4), dp(28), dp(12)) + addView(left, LinearLayout.LayoutParams(0, -2, 1f).apply { marginEnd = dp(28) }) + addView(right, LinearLayout.LayoutParams(0, -2, 1f)) + } + val scroll = android.widget.ScrollView(this).apply { addView(content) } + val dialog = AlertDialog.Builder(this).setTitle("声音设置") + .setView(scroll) + .setNegativeButton("关闭", null) + .setPositiveButton("确认") { _, _ -> + orderedSongOriginal = pendingOrderedSongOriginal + orderedVolumeMode = pendingOrderedVolumeMode + orderedVocalMode = pendingOrderedVocalMode + musicVolume = pendingMusicVolume + applyPlaybackMode() + saveState() + }.create() + dialog.showForTv(DialogInterface.BUTTON_NEGATIVE) { + val available = (resources.displayMetrics.widthPixels * 0.92f).toInt() + dialog.window?.setLayout(min(dp(790), available), ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + + private fun showSingModeDialog() { + val items = arrayOf("普通演唱", "评分演唱", "静音练唱") + AlertDialog.Builder(this) + .setTitle("演唱模式 (当前: " + singMode + ")") + .setItems(items, DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + singMode = items[w] + if ("评分演唱" == singMode) scoreEnabled = true + else if ("静音练唱" == singMode) { + scoreEnabled = false + musicVolume = 0 + } + saveState() + applyPlaybackMode() + toast("演唱模式: " + singMode) + }).create().showForTv() + } + + private fun showVocalChannelDialog() { + val items = arrayOf("自动", "左伴右原", "右伴左原", "全声道") + AlertDialog.Builder(this) + .setTitle("声道模式 (当前: $vocalChannelMode)") + .setItems(items) { _, which -> + setVocalChannelMode(items[which]) + showSettingsSection(3) + } + .create().showForTv() + } + + private fun showMarqueeDialog() { + val status = if (marqueeEnabled) "已开启: " + marqueeText else "已关闭" + AlertDialog.Builder(this) + .setTitle("跑马灯设置 - " + status) + .setItems( + arrayOf("开启", "关闭", "编辑文字"), + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + if (w == 0) { + marqueeEnabled = true + saveState() + } else if (w == 1) { + marqueeEnabled = false + saveState() + } else { + val input = EditText(this) + input.setText(marqueeText) + input.setHint("输入跑马灯文字") + AlertDialog.Builder(this) + .setTitle("编辑跑马灯文字") + .setView(input) + .setPositiveButton( + "确定", + DialogInterface.OnClickListener { dd: DialogInterface?, ww: Int -> + marqueeText = input.getText().toString() + marqueeEnabled = true + saveState() + }).setNegativeButton("取消", null).create().showForTv() + } + toast("跑马灯: " + (if (marqueeEnabled) marqueeText else "已关闭")) + }).create().showForTv() + } + + private fun showNetworkDlg() { + var ip = "未知" + try { + val en = NetworkInterface.getNetworkInterfaces() + while (en.hasMoreElements()) { + val ni = en.nextElement() + val addr = ni.getInetAddresses() + while (addr.hasMoreElements()) { + val ia = addr.nextElement() + if (!ia.isLoopbackAddress() && ia is Inet4Address) { + ip = ia.getHostAddress().orEmpty() + } + } + } + } catch (ignored: Exception) { + } + AlertDialog.Builder(this) + .setTitle("网络设置") + .setMessage("IP: " + ip + "\n手机遥控端口: 8765\n扫码连接手机点歌") + .setPositiveButton("确定", null).create().showForTv(DialogInterface.BUTTON_POSITIVE) + } + + private fun showAboutDialog() { + AlertDialog.Builder(this) + .setTitle("关于麦动") + .setMessage( + "power by 抖音@杨家三郎\n" + + "此软件免费开源,请勿付费获取\n" + + "版本:" + BuildConfig.VERSION_NAME + ) + .setPositiveButton("确定", null).create().showForTv(DialogInterface.BUTTON_POSITIVE) + } + + private fun applyScreenMode() { + if (player != null) { + try { + applyPlaybackMode() + } catch (ignored: Exception) { + } + } + } + + /** + * 更新分类列表 UI + */ + private fun updateCategories() { + val focusedCategory = window.decorView.findFocus()?.takeIf { isDescendantOf(it, categoryList) } + ?.let(::captureFocusBookmark) + categoryList!!.removeAllViews() + subCategoryScroll?.visibility = if (currentCategories.isEmpty()) View.GONE else View.VISIBLE + for (i in currentCategories.indices) { + val index = i + val cat = TextView(this) + cat.setText(currentCategories.get(i)) + cat.setTextSize(16f) + cat.setPadding(dp(12), 0, dp(12), 0) + cat.gravity = Gravity.CENTER + cat.setAccessibleFocus("focus:category:${currentCategories[i]}", "分类,${currentCategories[i]}") + cat.setFocusable(true) + cat.setClickable(true) + // 设置选中状态 + if (i == currentCategoryIndex) { + cat.setTextColor(Color.rgb(253, 51, 89)) + cat.setBackgroundColor(Color.argb(30, 253, 51, 89)) + } else { + cat.setTextColor(Color.argb(200, 241, 241, 241)) + cat.setBackgroundColor(Color.TRANSPARENT) + } + cat.setOnClickListener(View.OnClickListener { v: View? -> + currentCategoryIndex = index + updateCategories() + // 根据分类加载歌曲 + val category = currentCategories.get(index) + clearSearchForFilterChange() + when (currentTabIndex) { + 0 -> if ("全部" == category) { + browseMode = "hot" + browseParam = "" + searchLanguage = "全部" + } else { + browseMode = "language" + browseParam = category + searchLanguage = category + } + + 1 -> { + browsePage = 0 + loadSingerList(category) + return@OnClickListener + } + + 2 -> { + browseMode = "language" + browseParam = category + } + + 3 -> { + browseMode = "wordcount" + browseParam = getWordCountParam(category) + } + + 5 -> { + browseMode = "playlist" + browseParam = category + } + + 9 -> { + browseMode = "rank" + browseParam = rankPlaylistIds.getOrNull(index).orEmpty() + listTitle!!.text = category + } + + 10 -> { + browseMode = "local" + browseParam = category + searchLanguage = category + browsePage = 0 + loadLocalCatalogPage() + return@OnClickListener + } + } + browsePage = 0 + loadBrowsePage() + }) + installPressFeedback(cat) + categoryList!!.addView(cat) + } + restoreFocusBookmark(focusedCategory) + } + + /** + * 将字数分类转换为查询参数 + */ + private fun getWordCountParam(category: String): String { + when (category) { + "2字" -> return "2" + "3字" -> return "3" + "4字" -> return "4" + "5字" -> return "5" + "6字" -> return "6" + else -> return "7" // 7字以上 + } + } + + /** + * 执行搜索 + */ + private fun performSearch() { + val query = searchInput?.text?.toString()?.trim().orEmpty() + activeSearchQuery = query + browsePage = 0 + when (searchScope) { + "quick" -> loadQuickSearchPage(query) + "singer" -> { + val category = currentCategories.getOrNull(currentCategoryIndex) ?: "全部" + loadSingerList(category, query) + } + "playlist" -> loadCategoryPlaylistPage(query) + "local" -> loadLocalCatalogPage() + "singer_songs" -> { + browseMode = if (query.isEmpty()) "singer_id" else "singer_id_search" + browseParam = searchContextId + loadBrowsePage() + } + "playlist_songs" -> { + browseMode = if (query.isEmpty()) "playlist_id" else "playlist_id_search" + browseParam = searchContextId + loadBrowsePage() + } + else -> { + searchLanguage = currentCategories.getOrNull(currentCategoryIndex) ?: "全部" + if (query.isEmpty()) { + browseMode = if (searchLanguage == "全部") "hot" else "language" + browseParam = if (searchLanguage == "全部") "" else searchLanguage + listTitle?.text = if (searchLanguage == "全部") "热门歌曲" else searchLanguage + } else { + browseMode = "search" + browseParam = query + listTitle?.text = "搜索结果: $query" + } + loadBrowsePage() + } + } + } + + /** + * 加载浏览分页数据 + */ + private fun loadBrowsePage() { + if (!library.muse.isAvailable()) { + catalogRefreshPending = true + busy(true, "曲库加载中...") + return + } + catalogRefreshPending = false + busy(false, "") + val requestVersion = ++browseRequestVersion + val requestedMode = browseMode + val requestedParam = browseParam.orEmpty() + val requestedPage = browsePage + val requestedLanguage = searchLanguage + val requestedSearchQuery = activeSearchQuery + val pageSize = when (requestedMode) { + "rank" -> 6 + "singer_list" -> 8 + else -> MuseDatabase.PAGE_SIZE + } + val key = BrowseCacheKey( + requestedMode, + requestedParam, + requestedLanguage, + requestedSearchQuery, + requestedPage, + pageSize, + ) + val cached = synchronized(browsePageCache) { browsePageCache[key] } + if (cached != null) { + applyBrowsePage(key, cached, requestVersion) + prefetchBrowseNeighbors(key, cached.total) + return + } + requestBrowsePage(key, requestVersion, true) + } + + private fun browseCountKey(key: BrowseCacheKey): String = + listOf(key.mode, key.param, key.language, key.searchQuery).joinToString("\u0001") + + private fun requestBrowsePage(key: BrowseCacheKey, requestVersion: Int, display: Boolean) { + if (display) synchronized(browseDisplayRequests) { browseDisplayRequests[key] = requestVersion } + synchronized(browseRequestsInFlight) { + if (!browseRequestsInFlight.add(key)) return + } + io.execute(Runnable { + try { + val result = queryBrowsePage(key) + synchronized(browsePageCache) { browsePageCache[key] = result } + val displayVersion = synchronized(browseDisplayRequests) { browseDisplayRequests.remove(key) } + if (displayVersion != null) main.post { applyBrowsePage(key, result, displayVersion) } + prefetchBrowseNeighbors(key, result.total) + } finally { + synchronized(browseRequestsInFlight) { browseRequestsInFlight.remove(key) } + } + }) + } + + private fun queryBrowsePage(key: BrowseCacheKey): BrowsePageResult { + var songs: MutableList? = null + val countKey = browseCountKey(key) + var total = synchronized(browseCountCache) { browseCountCache[countKey] } ?: -1 + val offset = key.page * key.pageSize + if (library.muse.isAvailable()) { + when (key.mode) { + "hot" -> { + songs = library.muse.hotSongs(offset, key.pageSize) + if (total < 0) total = library.muse.songCount() + } + + "guess" -> { + songs = library.muse.hotSongs(offset, key.pageSize) + if (total < 0) total = library.muse.songCount() + } + + "rank" -> { + songs = library.muse.rankSongs(key.param, offset, key.pageSize) + if (total < 0) total = library.muse.rankSongCount(key.param) + } + + "language" -> { + songs = library.muse.songsByLanguage(key.param, offset, key.pageSize) + if (total < 0) total = library.muse.countByLanguage(key.param) + } + + "wordcount" -> { + val wc = parseIntSafe(key.param, 0) + songs = library.muse.songsByWordCount(wc, offset, key.pageSize) + if (total < 0) total = library.muse.countByWordCount(wc) + } + + "singer" -> { + songs = library.muse.songsBySingerName(key.param, offset, key.pageSize) + if (total < 0) total = library.muse.countSongsBySingerName(key.param) + } + + "singer_id" -> { + songs = library.muse.songsBySinger(key.param, offset, key.pageSize) + if (total < 0) total = library.muse.countSongsBySinger(key.param) + } + + "singer_id_search" -> { + songs = library.muse.searchSongsBySinger(key.param, key.searchQuery, offset, key.pageSize) + if (total < 0) total = library.muse.countSearchSongsBySinger(key.param, key.searchQuery) + } + + "search" -> { + songs = library.muse.searchSongs(key.param, offset, key.pageSize, key.language) + if (total < 0) total = library.muse.searchSongCount(key.param, key.language) + } + + "favorite" -> { + // 收藏功能暂未实现,返回空列表 + songs = ArrayList() + total = 0 + } + + "playlist" -> { + // 歌单查询(通过歌单名称查找 ID) + val plist: MutableList> = library.muse.playlists(0, 100) + var playlistId = "" + for (p in plist) { + if (key.param == p[1]) { + playlistId = p[0].orEmpty() + break + } + } + if (!playlistId.isEmpty()) { + songs = library.muse.songsInPlaylist(playlistId, offset, key.pageSize) + if (total < 0) total = library.muse.playlistSongCount(playlistId) + } else { + songs = ArrayList() + total = 0 + } + } + + "playlist_id" -> { + songs = library.muse.songsInPlaylist(key.param, offset, key.pageSize) + if (total < 0) total = library.muse.playlistSongCount(key.param) + } + + "playlist_id_search" -> { + songs = library.muse.searchSongsInPlaylist(key.param, key.searchQuery, offset, key.pageSize) + if (total < 0) total = library.muse.countSearchSongsInPlaylist(key.param, key.searchQuery) + } + } + } + if (total < 0) total = 0 + synchronized(browseCountCache) { browseCountCache[countKey] = total } + return BrowsePageResult(songs.orEmpty(), total) + } + + private fun applyBrowsePage(key: BrowseCacheKey, result: BrowsePageResult, requestVersion: Int) { + if (requestVersion != browseRequestVersion || key.mode != browseMode || + key.param != browseParam.orEmpty() || key.page != browsePage || + key.language != searchLanguage || key.searchQuery != activeSearchQuery + ) return + browseTotalCount = result.total + browseTotalPages = (result.total + key.pageSize - 1) / key.pageSize + visibleSongs.clear() + visibleSongs.addAll(result.songs) + renderSongList() + updatePageInfo() + } + + private fun prefetchBrowseNeighbors(key: BrowseCacheKey, total: Int) { + val totalPages = (total + key.pageSize - 1) / key.pageSize + listOf(key.page - 1, key.page + 1) + .filter { it in 0 until totalPages } + .forEach { page -> + val neighbor = key.copy(page = page) + val cached = synchronized(browsePageCache) { browsePageCache.containsKey(neighbor) } + if (!cached) requestBrowsePage(neighbor, browseRequestVersion, false) + } + } + + private fun refreshCurrentCatalogPage() { + catalogRefreshPending = false + synchronized(browsePageCache) { browsePageCache.clear() } + synchronized(browseCountCache) { browseCountCache.clear() } + busy(false, "曲库加载完成") + when { + browseMode == "singer_list" -> loadSingerList(browseParam) + browseMode == "category_list" -> loadCategoryPlaylistPage(activeSearchQuery) + browseMode == "quick" -> loadQuickSearchPage(activeSearchQuery) + currentTabIndex == 3 -> loadWordCounts() + currentTabIndex == 7 -> loadFavorites() + else -> loadBrowsePage() + } + } + + /** + * 轻量刷新当前歌曲列表 Adapter (不重建), 用于下载状态变更后即时更新 UI。 + */ + private fun refreshSongAdapter() { + modernSongAdapter?.let { adapter -> + if (songList?.adapter === adapter) adapter.notifyDataSetChanged() + } + } + + /** + * 渲染歌曲列表 + */ + private fun renderSongList() { + val list = songList ?: return + val focusedView = window.decorView.findFocus()?.takeIf { isDescendantOf(it, list) } + val focusedMarker = focusedView?.focusMarker() + ?.takeIf { it.startsWith(SongListAdapter.FOCUS_PREFIX) } + val oldSelectedPosition = list.selectedItemPosition + val oldFocusedTop = focusedView?.top + if (browseMode == "ordered") { + visibleSongs.clear() + visibleSongs.addAll(orderQueue.orEmpty()) + } else if (browseMode == "sang") { + visibleSongs.clear() + visibleSongs.addAll(sangHistory) + } + if (visibleSongs.isEmpty()) { + modernSongAdapter = null + list.adapter = tvAdapter().apply { add("暂无歌曲") } + return + } + list.dividerHeight = 0 + val mode = when (browseMode) { + "rank", "ordered", "downloads", "sang" -> browseMode + else -> "catalog" + } + val pageOffset = if (mode == "rank") browsePage * browsePageSize() else 0 + val existing = modernSongAdapter?.takeIf { it.matches(mode, pageOffset) && list.adapter === it } + val listAdapter = existing ?: SongListAdapter( + this, + visibleSongs, + mode, + pageOffset, + btnPrevPage?.id ?: View.NO_ID, + btnNextPage?.id ?: View.NO_ID, + { song -> songRowState(song) }, + SongListCallbacks( + onPrimary = { song -> handleSongPrimary(song) }, + onDownload = { song -> downloadSong(song) }, + onMore = { song -> showSongActions(song) }, + onTop = { song -> moveQueuedSongToNext(song) }, + onDelete = { song -> + if (browseMode == "downloads") deleteDownloadedSong(song) else deleteQueuedSong(song) + }, + onPauseResume = { song -> toggleDownloadPause(song) }, + ), + ).also { + modernSongAdapter = it + list.adapter = it + } + if (existing != null) listAdapter.notifyDataSetChanged() + + val targetPosition = focusedMarker?.let(listAdapter::adapterPositionFor) + ?: oldSelectedPosition.takeIf { it >= 0 } + if (targetPosition != null && focusedMarker != null) { + list.setSelectionFromTop(targetPosition, oldFocusedTop ?: 0) + list.post { + if (list.adapter !== listAdapter) return@post + list.setSelection(targetPosition) + val row = list.getChildAt(targetPosition - list.firstVisiblePosition) + val target = row?.let { findViewByFocusMarker(it, focusedMarker) } + if (target != null) { + target.requestFocus() + target.refreshDrawableState() + target.jumpDrawablesToCurrentState() + target.invalidate() + } + } + } + } + + private fun findViewByFocusMarker(root: View, marker: String): View? { + if (root.focusMarker() == marker) return root + if (root is ViewGroup) repeat(root.childCount) { index -> + findViewByFocusMarker(root.getChildAt(index), marker)?.let { return it } + } + return null + } + + private fun songRowState(song: Song): SongRowState { + val task = downloads.lastOrNull { stableId(it.song) == stableId(song) } + val taskState = task?.state.orEmpty() + return SongRowState( + current = currentSong?.equals(song) == true, + queued = orderQueue!!.contains(song), + local = song.hasLocalFile(), + downloading = isDownloading(song), + failed = taskState.startsWith("失败"), + paused = taskState.contains("暂停") || taskState.contains("中断"), + progress = task?.progress ?: SongOkDownloadManager.getDownloadProgress(song), + ) + } + + private fun handleSongPrimary(song: Song) { + var rendered = false + when (browseMode) { + "ordered" -> { + if (currentSong?.equals(song) == true) toast("正在播放: ${song.title}") + else moveQueuedSongToNext(song) + rendered = true + } + "downloads" -> addToQueue(song) + "sang" -> addToQueue(song) + else -> addToQueue(song) + } + persistRuntimeState() + if (!rendered) renderSongList() + } + + private fun moveQueuedSongToNext(song: Song) { + if (currentSong?.equals(song) == true) return + orderQueue!!.remove(song) + val target = if (currentSong != null && orderQueue!!.isNotEmpty()) 1 else 0 + orderQueue.add(target.coerceAtMost(orderQueue.size), song) + persistRuntimeState() + syncPlayerInfo() + renderSongList() + } + + private fun deleteQueuedSong(song: Song) { + if (currentSong?.equals(song) == true) { + toast("正在播放的歌曲不能删除") + return + } + orderQueue!!.remove(song) + persistRuntimeState() + syncPlayerInfo() + renderSongList() + } + + private fun toggleDownloadPause(song: Song) { + val task = downloads.lastOrNull { stableId(it.song) == stableId(song) } + if (isDownloading(song)) { + SongOkDownloadManager.cancelDownload(song) + task?.state = "已暂停" + stateIo.execute { stateDatabase.updateDownload(song, "paused", task?.progress ?: 0) } + renderSongList() + } else { + downloadSong(song) + } + } + + private fun deleteDownloadedSong(song: Song) { + if (currentSong?.equals(song) == true) { + toast("正在播放的歌曲不能删除") + return + } + SongOkDownloadManager.cancelDownload(song) + val files = listOfNotNull(song.path?.let(::File), SongOkDownloadManager.getLocalFile(song)) + .distinctBy { it.absolutePath } + io.execute { + val failed = files.filter { file -> + if (!file.exists()) return@filter false + cleanupSidecars(file) + !runCatching { file.delete() }.getOrDefault(false) + } + if (failed.isEmpty()) { + song.path = null + runCatching { stateDatabase.removeDownload(song) } + main.post { + downloads.removeAll { stableId(it.song) == stableId(song) } + if (browseMode == "downloads") loadDownloadedList() else reloadCurrentBrowsePage() + toast("已删除本地文件") + } + } else { + main.post { toast("文件删除失败,请检查存储权限") } + } + } + } + + /** + * 更新分页信息 + */ + private fun updatePageInfo() { + textPageInfo!!.setText(String.format("第%d/%d页", browsePage + 1, max(1, browseTotalPages))) + } + + /** + * 上一页 + */ + private fun prevPage() { + if (browsePage > 0) { + browsePage-- + reloadCurrentBrowsePage() + } + } + + /** + * 下一页 + */ + private fun nextPage() { + if (browsePage < browseTotalPages - 1) { + browsePage++ + reloadCurrentBrowsePage() + } + } + + private fun reloadCurrentBrowsePage() { + when (browseMode) { + "singer_list" -> loadSingerList(browseParam, activeSearchQuery) + "local" -> loadLocalCatalogPage() + "category_list" -> loadCategoryPlaylistPage(activeSearchQuery) + "quick" -> loadQuickSearchPage(activeSearchQuery) + else -> loadBrowsePage() + } + } + + /** + * 显示已点列表对话框 + */ + private fun showQueueDialog() { + val builder = AlertDialog.Builder(this) + builder.setTitle("已点列表 (" + orderQueue!!.size + ")") + if (orderQueue.isEmpty()) { + builder.setMessage("暂无已点歌曲") + builder.setPositiveButton("确定", null) + } else { + val items: Array = Array(orderQueue.size) { "" } + for (i in orderQueue.indices) { + val s = orderQueue.get(i) + val marker = if (currentSong != null && currentSong!!.equals(s)) " ▶" else "" + items[i] = (i + 1).toString() + ". " + s.title + " - " + s.singer + marker + } + builder.setItems( + items, + DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> + val selected = orderQueue.get(which) + if (currentSong != null && currentSong!!.equals(selected)) { + toast("正在播放: " + selected.title) + } else { + // 只设为下一首,不打断当前歌曲。 + moveQueuedSongToNext(selected) + } + }) + builder.setNegativeButton( + "删除选中", + DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> + // 显示删除对话框 + val delItems: Array = Array(orderQueue.size) { "" } + for (i in orderQueue.indices) { + delItems[i] = orderQueue.get(i).title + " - " + orderQueue.get(i).singer + } + AlertDialog.Builder(this) + .setTitle("选择要删除的歌曲") + .setItems( + delItems, + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + val removed = orderQueue.removeAt(w) + if (currentSong != null && currentSong!!.equals(removed)) { + playNext() + } + updateOrderBadge() + saveState() + if (currentTabIndex == 4) loadOrderedList() + toast("已删除: " + removed.title) + }) + .create().showForTv() + }) + builder.setNeutralButton( + "清空全部", + DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> + AlertDialog.Builder(this) + .setTitle("确认清空") + .setMessage("确定要清空全部" + orderQueue.size + "首已点歌曲吗?") + .setPositiveButton( + "确定", + DialogInterface.OnClickListener { d: DialogInterface?, w: Int -> + orderQueue.clear() + if (player != null) player!!.stopPlayback() + playbackPreparing = false + playWhenPrepared = false + currentMediaPlayer = null + releaseVocalPlayer() + currentSong = null + clearLyrics() + updateBottomBar(null, false) + updateOrderBadge() + saveState() + if (currentTabIndex == 4) loadOrderedList() + toast("已清空列表") + }) + .setNegativeButton("取消", null) + .create().showForTv() + }) + } + builder.create().showForTv() + } + + /** + * 更新已点数量徽标(顶栏 + 子页面旧按钮) + */ + private fun updateOrderBadge() { + val n = if (orderQueue == null) 0 else orderQueue.size + if (textTopOrderBadge != null) textTopOrderBadge!!.setText(n.toString()) + if (textOrderBadge != null) textOrderBadge!!.setText(n.toString()) + } + + /** + * 添加歌曲到已点列表 + */ + private fun addToQueue(song: Song) { + if (orderQueue!!.contains(song)) { + toast("已在列表中: " + song.title) + return + } + if (!isSongReadyForQueue(song)) { + val id = stableId(song) + val newlyPending = pendingQueueSongIds.add(id) + toast( + if (newlyPending) "正在下载,完成后自动加入已点:${song.title}" + else "歌曲正在下载,尚未进入已点:${song.title}", + ) + if (!isDownloading(song)) downloadSong(song) + return + } + addReadySongToQueue(song) + } + + private fun isSongReadyForQueue(song: Song): Boolean = + SongOkDownloadManager.isDownloaded(song) || song.hasLocalFile() + + private fun addReadySongToQueue(song: Song) { + if (orderQueue!!.contains(song)) return + val wasQueueEmpty = orderQueue!!.isEmpty() + orderQueue.add(song) + song.playCount++ + if (::stateDatabase.isInitialized) runCatching { + stateIo.execute { stateDatabase.markSongClicked(song) } + } + saveState() + updateOrderBadge() + if (currentPage == "已点" || currentPage == "首页") renderQueue() + toast("已点: " + song.title + (if (song.singer != null) " - " + song.singer else "")) + if (wasQueueEmpty) play(song) + } + + private fun scheduleAutoFullscreen() { + main.removeCallbacks(autoFullscreenRunnable) + if (autoFullscreenSeconds > 0 && currentSong != null && playWhenPrepared && !isFullScreen) { + main.postDelayed(autoFullscreenRunnable, autoFullscreenSeconds * 1000L) + } + } + + override fun onUserInteraction() { + super.onUserInteraction() + if (!isFullScreen) scheduleAutoFullscreen() + } + + private fun busy(show: Boolean, message: String?) { + if (progress != null) { + progress!!.setVisibility(if (show) View.VISIBLE else View.GONE) + } + if (status != null) { + status!!.setText(message) + } + } + + private fun toast(text: String?) { + Toast.makeText(this, text, Toast.LENGTH_SHORT).show() + } + + private fun prefs(): SharedPreferences? { + return getSharedPreferences(PREFS, MODE_PRIVATE) + } + + /** + * 将当前运行时状态同步到 KtvStore 并持久化到磁盘。 + * + * + * 供 Fragment 在修改已点队列、收藏等数据后调用,确保状态不丢失。 + */ + fun saveState() { + store.loopOne = loopOne + store.autoNext = autoNext + store.originalVocal = originalVocal + store.vocalChannelMode = vocalChannelMode + store.musicVolume = musicVolume + store.micVolume = micVolume + store.tone = tone + store.atmosphere = atmosphere + store.singMode = singMode + store.recordEnabled = recordEnabled + store.scoreEnabled = scoreEnabled + store.marqueeEnabled = marqueeEnabled + store.marqueeText = marqueeText!! + store.playWhileDownloading = playWhileDownloading + store.clearDownloadsOnBoot = clearDownloadsOnBoot + store.autoFullscreenSeconds = autoFullscreenSeconds + store.showUsbSongs = showUsbSongs + store.autoDeleteSongs = autoDeleteSongs + store.reserveStorageGb = reserveStorageGb + store.floatingButtonEnabled = floatingButtonEnabled + store.songTitleSubtitleEnabled = songTitleSubtitleEnabled + store.orderedSongOriginal = orderedSongOriginal + store.orderedVolumeMode = orderedVolumeMode + store.orderedVocalMode = orderedVocalMode + store.voiceEngineEnabled = voiceEngineEnabled + store.voiceEngineVolume = voiceEngineVolume + store.lightMode = lightMode!! + store.audioDelayMs = audioDelayMs + store.screenBrightness = screenBrightness + store.screenMode = screenMode + store.doubleScreenEnabled = doubleScreenEnabled + store.tableBroadcastEnabled = tableBroadcastEnabled + store.tableBroadcastSeconds = tableBroadcastSeconds + store.tableBroadcastText = tableBroadcastText!! + val storeSnapshot = store.snapshotJson() + runCatching { stateIo.execute { store.writeSnapshot(storeSnapshot) } } + persistRuntimeState() + } + + private fun persistRuntimeState() { + if (!::stateDatabase.isInitialized) return + val playbackState = when { + currentSong == null -> "idle" + playbackPreparing && playWhenPrepared -> "preparing" + !playWhenPrepared -> "paused" + player?.isPlaying == true -> "playing" + else -> "queued" + } + val queueSnapshot = orderQueue.orEmpty().take(200).map { Song.fromJson(it.toJson()) } + val historySnapshot = sangHistory.take(200).map { Song.fromJson(it.toJson()) } + val currentId = currentSong?.let(::stableId) + val currentSnapshot = currentId?.let { id -> + queueSnapshot.firstOrNull { stableId(it) == id } + } + runCatching { + stateIo.execute { + stateDatabase.syncQueue(queueSnapshot, historySnapshot, currentSnapshot, playbackState) + } + } + } + + private fun loadStateFromStore() { + loopOne = store.loopOne + autoNext = store.autoNext + originalVocal = store.originalVocal + vocalChannelMode = store.vocalChannelMode + musicVolume = store.musicVolume + micVolume = store.micVolume + tone = store.tone.coerceIn(-5, 5) + atmosphere = store.atmosphere + singMode = store.singMode + recordEnabled = store.recordEnabled + scoreEnabled = store.scoreEnabled + marqueeEnabled = store.marqueeEnabled + marqueeText = store.marqueeText + playWhileDownloading = store.playWhileDownloading + clearDownloadsOnBoot = store.clearDownloadsOnBoot + autoFullscreenSeconds = store.autoFullscreenSeconds + showUsbSongs = store.showUsbSongs + autoDeleteSongs = store.autoDeleteSongs + reserveStorageGb = store.reserveStorageGb + floatingButtonEnabled = store.floatingButtonEnabled + songTitleSubtitleEnabled = store.songTitleSubtitleEnabled + orderedSongOriginal = store.orderedSongOriginal + orderedVolumeMode = store.orderedVolumeMode + orderedVocalMode = store.orderedVocalMode + voiceEngineEnabled = store.voiceEngineEnabled + voiceEngineVolume = store.voiceEngineVolume + lightMode = store.lightMode + audioDelayMs = store.audioDelayMs + screenBrightness = store.screenBrightness + screenMode = store.screenMode + doubleScreenEnabled = store.doubleScreenEnabled + tableBroadcastEnabled = store.tableBroadcastEnabled + tableBroadcastSeconds = store.tableBroadcastSeconds + tableBroadcastText = store.tableBroadcastText + } + + private fun dp(value: Int): Int { + return (value * getResources().getDisplayMetrics().density + 0.5f).toInt() + } + + private fun hasStoragePermission(): Boolean = + Build.VERSION.SDK_INT < 23 || + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + + private fun requestStorage() { + if (Build.VERSION.SDK_INT >= 23 + && checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ), 10 + ) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 10 && grantResults.isNotEmpty() && + grantResults.all { it == PackageManager.PERMISSION_GRANTED } + ) { + recreate() + } + if (requestCode == 11 && grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) startRecording() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.getAction() != KeyEvent.ACTION_DOWN) return super.dispatchKeyEvent(event) + focusRestoreSerial++ + val code = event.getKeyCode() + if (isFullScreen) { + val center = code == KeyEvent.KEYCODE_DPAD_CENTER || code == KeyEvent.KEYCODE_ENTER || + code == KeyEvent.KEYCODE_NUMPAD_ENTER + val direction = code == KeyEvent.KEYCODE_DPAD_LEFT || code == KeyEvent.KEYCODE_DPAD_RIGHT || + code == KeyEvent.KEYCODE_DPAD_UP || code == KeyEvent.KEYCODE_DPAD_DOWN + val volumePanelVisible = fullScreenVolumePanel?.visibility == View.VISIBLE + val volumePanelHasFocus = volumePanelVisible && fullScreenVolumePanel?.hasFocus() == true + if (code == KeyEvent.KEYCODE_BACK && volumePanelVisible) { + main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable) + fullScreenVolumePanel?.visibility = View.GONE + fullScreenToneControl?.requestFocus() + setFullScreenChromeVisible(true) + return true + } + if (center && !fullScreenChromeVisible) { + setFullScreenChromeVisible(true) + return true + } + if (volumePanelVisible && (center || direction) && !volumePanelHasFocus && + fullScreenControls?.hasFocus() != true + ) { + setFullScreenChromeVisible(true) + fullScreenVolumeMinusButton?.requestFocus() + return true + } + if (center && fullScreenControls?.hasFocus() != true && !volumePanelHasFocus) { + setFullScreenChromeVisible(true) + fullScreenControls?.getChildAt(0)?.requestFocus() + return true + } + if (direction && !fullScreenChromeVisible) { + setFullScreenChromeVisible(true) + return true + } + if ((center || direction) && fullScreenChromeVisible) { + main.removeCallbacks(hideFullScreenChromeRunnable) + main.postDelayed(hideFullScreenChromeRunnable, 5000L) + } + if (volumePanelHasFocus && code == KeyEvent.KEYCODE_DPAD_UP) { + fullScreenToneControl?.requestFocus() + return true + } + if (volumePanelHasFocus && code == KeyEvent.KEYCODE_DPAD_DOWN) { + return true + } + if (volumePanelVisible && window.decorView.findFocus() === fullScreenToneControl && + code == KeyEvent.KEYCODE_DPAD_DOWN + ) { + fullScreenVolumeMinusButton?.requestFocus() + return true + } + } + if (code == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || code == KeyEvent.KEYCODE_SPACE) { + togglePlay() + return true + } + if (code == KeyEvent.KEYCODE_MEDIA_NEXT) { + playNext() + return true + } + if (code == KeyEvent.KEYCODE_BACK) { + if (isFullScreen) { + exitFullScreenPlayer() + return true + } + if (currentTabIndex != -1 || "首页" != currentPage) { + navigateBack() + return true + } + } + val homeDirection = homeLayout?.visibility == View.VISIBLE && currentPage == "首页" && + (code == KeyEvent.KEYCODE_DPAD_LEFT || code == KeyEvent.KEYCODE_DPAD_RIGHT || + code == KeyEvent.KEYCODE_DPAD_UP || code == KeyEvent.KEYCODE_DPAD_DOWN) + if (homeDirection) { + val focused = window.decorView.findFocus() + val reverseTarget = lastHomeFocusSource + if (focused === lastHomeFocusTarget && code == lastHomeFocusReverseKey && + reverseTarget?.isAttachedToWindow == true && reverseTarget.isShown && reverseTarget.isEnabled + ) { + if (reverseTarget.requestFocus()) { + lastHomeFocusSource = focused + lastHomeFocusTarget = reverseTarget + lastHomeFocusReverseKey = oppositeDpadKey(code) + return true + } + clearHomeFocusTransition() + } + + val handled = super.dispatchKeyEvent(event) + main.post { + val destination = window.decorView.findFocus() + if (focused != null && destination != null && destination !== focused && + homeLayout?.visibility == View.VISIBLE && currentPage == "首页" + ) { + lastHomeFocusSource = focused + lastHomeFocusTarget = destination + lastHomeFocusReverseKey = oppositeDpadKey(code) + } + } + return handled + } + return super.dispatchKeyEvent(event) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) window.decorView.post { prepareTvFocusableTree(window.decorView) } + } + + private fun navigateBack() { + val returnPoint = focusReturnStack.pollLast() + if (returnPoint != null) { + restoringFocusRoute = true + try { + returnPoint.restorePage() + } finally { + restoringFocusRoute = false + } + restoreFocusBookmark(returnPoint.focus, returnPoint.directFocus) + return + } + when (browseMode) { + "playlist_id", "playlist_id_search" -> returnToCategoryPlaylists() + "settings_section" -> showSettingsPage() + else -> showHomePage() + } + } + + private fun navigateBackToHome() { + while (focusReturnStack.size > 1) focusReturnStack.pollLast() + if (focusReturnStack.isNotEmpty()) navigateBack() else showHomePage() + } + + private fun rememberReturnPointIfNeeded(targetPage: String) { + if (restoringFocusRoute || targetPage == currentPage) return + val focus = captureFocusBookmark(window.decorView.findFocus()) + val fromHome = homeLayout?.visibility == View.VISIBLE || currentPage == "首页" + if (fromHome) { + focusReturnStack.clear() + focusReturnStack.addLast(FocusReturnPoint({ showHomePage() }, focus, window.decorView.findFocus())) + return + } + val currentDepth = currentPage.count { it == '/' } + val targetDepth = targetPage.count { it == '/' } + if (targetDepth <= currentDepth) return + + val savedMode = browseMode + val savedParam = browseParam + val savedPage = browsePage + val savedQuery = activeSearchQuery + val savedCategory = currentCategories.getOrNull(currentCategoryIndex) + val restorePage: () -> Unit = when (savedMode) { + "singer_list" -> ({ + loadSingers() + browsePage = savedPage + savedCategory?.let { browseParam = it } + activeSearchQuery = savedQuery + loadSingerList(savedCategory ?: "全部", savedQuery) + }) + "category_list" -> ({ + searchScope = "playlist" + activeSearchQuery = savedQuery + setSearchText(savedQuery) + currentTabIndex = 2 + showSubPageShell("主页 / 分类") + setupTabs() + currentCategories.clear() + currentCategoryIndex = -1 + updateCategories() + browseMode = "category_list" + browseParam = savedParam + browsePage = savedPage + loadCategoryPlaylistPage(savedQuery) + }) + "settings" -> ({ showSettingsPage(false) }) + else -> ({ showHomePage() }) + } + focusReturnStack.addLast(FocusReturnPoint(restorePage, focus, window.decorView.findFocus())) + } + + private fun captureFocusBookmark(view: View?): FocusBookmark? { + if (view == null) return null + val resourceName = if (view.id != View.NO_ID && (view.id ushr 24) != 0) { + runCatching { resources.getResourceName(view.id) }.getOrNull() + } else null + val marker = view.focusMarker() + val text = (view as? TextView)?.text?.toString()?.takeIf { it.isNotBlank() } + val groupText = if (view is ViewGroup) collectFocusText(view).takeIf { it.isNotBlank() } else null + return FocusBookmark(resourceName, marker, text, groupText) + } + + private fun collectFocusText(view: View): String { + val values = ArrayList(3) + fun collect(node: View) { + if (values.size >= 3) return + if (node is TextView) { + node.text?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add) + } else if (node is ViewGroup) { + for (index in 0 until node.childCount) collect(node.getChildAt(index)) + } + } + collect(view) + return values.joinToString("|") + } + + private fun restoreFocusBookmark(bookmark: FocusBookmark?, preferredView: View? = null) { + if (bookmark == null && preferredView == null) return + val serial = ++focusRestoreSerial + val settingsList = songList + val settingsAdapter = settingsList?.adapter as? SettingsListAdapter + val settingsPosition = bookmark?.marker?.let { marker -> + settingsAdapter?.positionForMarker(marker) + } + if (settingsList != null && settingsAdapter != null && settingsPosition != null) { + settingsAdapter.requestFocusAt(settingsList, settingsPosition) + return + } + var restored = false + listOf(0L, 60L, 180L, 450L, 900L).forEach { delay -> + main.postDelayed({ + if (restored || serial != focusRestoreSerial) return@postDelayed + val target = preferredView?.takeIf { it.isAttachedToWindow && it.isShown && it.isEnabled && it.isFocusable } + ?: bookmark?.let { findFocusTarget(window.decorView, it) } + if (target != null) { + restored = target.requestFocus() || target.requestFocusFromTouch() + if (restored) { + target.refreshDrawableState() + target.jumpDrawablesToCurrentState() + target.invalidate() + } + } + }, delay) + } + } + + private fun findFocusTarget(root: View, bookmark: FocusBookmark): View? { + var textMatch: View? = null + var groupMatch: View? = null + fun visit(view: View): View? { + if (view.visibility != View.VISIBLE || !view.isShown || !view.isEnabled) return null + if (view.isFocusable) { + val resourceName = if (view.id != View.NO_ID) { + runCatching { resources.getResourceName(view.id) }.getOrNull() + } else null + if (bookmark.resourceName != null && resourceName == bookmark.resourceName) return view + if (bookmark.marker != null && view.focusMarker() == bookmark.marker) return view + if (textMatch == null && bookmark.text != null && + (view as? TextView)?.text?.toString() == bookmark.text + ) textMatch = view + if (groupMatch == null && bookmark.groupText != null && view is ViewGroup && + collectFocusText(view) == bookmark.groupText + ) groupMatch = view + } + if (view is ViewGroup) { + for (index in 0 until view.childCount) visit(view.getChildAt(index))?.let { return it } + } + return null + } + return visit(root) ?: textMatch ?: groupMatch + } + + private fun isDescendantOf(view: View, ancestor: View?): Boolean { + if (ancestor == null) return false + var current: View? = view + while (current != null) { + if (current === ancestor) return true + current = current.parent as? View + } + return false + } + + private fun prepareTvFocusableTree(view: View) { + if (view.isClickable && view.visibility == View.VISIBLE && view.isEnabled) { + TvFocusStyler.install(view) + if (view.contentDescription.isNullOrBlank()) { + val spoken = when (view) { + is TextView -> view.text?.toString()?.trim().orEmpty() + is ViewGroup -> view.visibleTextLabel() + else -> "" + } + if (spoken.isNotEmpty()) view.contentDescription = spoken + } + } + if (view is ViewGroup) { + for (index in 0 until view.childCount) prepareTvFocusableTree(view.getChildAt(index)) + } + } + + private fun showFavorites() { + setPage("收藏") + content!!.removeAllViews() + val panel = panel() + content!!.addView(panel, LinearLayout.LayoutParams(-1, -1)) + panel.addView(sectionTitle("我的收藏")) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: kotlin.Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: kotlin.Long -> + showSongActions(visibleSongs.get(pos)) + true + }) + panel.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + visibleSongs.clear() + for (song in library.allSongs()) { + if (store.isFavorite(song)) visibleSongs.add(song) + } + renderSongs() + } + + private fun showPlaylists() { + setPage("歌单") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(300), -1)) + left.addView(sectionTitle("我的歌单")) + for (playlist in store.playlists) { + left.addView( + button( + playlist + " " + store.songsInPlaylist(playlist).size, + View.OnClickListener { v: View? -> showPlaylistSongs(playlist) }) + ) + } + left.addView( + button( + "新建歌单", + View.OnClickListener { v: View? -> showCreatePlaylistDialog() }) + ) + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle("歌单歌曲")) + right.addView( + label( + "选择左侧歌单查看歌曲;在点歌、排行、歌星等页面长按歌曲可加入或移出歌单。", + 20, + Color.LTGRAY + ) + ) + } + + private fun showPlaylistSongs(playlist: String) { + setPage("歌单") + content!!.removeAllViews() + val left = panel() + content!!.addView(left, LinearLayout.LayoutParams(dp(300), -1)) + left.addView(sectionTitle("我的歌单")) + for (item in store.playlists) { + left.addView( + button( + (if (item == playlist) "> " else "") + item + " " + store.songsInPlaylist(item).size, + View.OnClickListener { v: View? -> showPlaylistSongs(item) }) + ) + } + left.addView( + button( + "新建歌单", + View.OnClickListener { v: View? -> showCreatePlaylistDialog() }) + ) + + val right = panel() + content!!.addView(right, LinearLayout.LayoutParams(0, -1, 1f)) + right.addView(sectionTitle(playlist)) + songList = listView() + songAdapter = tvAdapter() + songList!!.setAdapter(songAdapter) + songList!!.setOnItemClickListener(OnItemClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: kotlin.Long -> + handleSong( + visibleSongs.get(pos) + ) + }) + songList!!.setOnItemLongClickListener(OnItemLongClickListener { p: AdapterView<*>?, v: View?, pos: Int, id: kotlin.Long -> + showSongActions(visibleSongs.get(pos)) + true + }) + right.addView(songList, LinearLayout.LayoutParams(-1, 0, 1f)) + visibleSongs.clear() + for (song in library.allSongs()) { + if (store.isInPlaylist(playlist, song)) visibleSongs.add(song) + } + renderSongs() + } + + private fun showPlaylistPicker(song: Song) { + val items: Array = Array(store.playlists.size) { "" } + for (i in store.playlists.indices) { + val playlist = store.playlists.get(i) + items[i] = (if (store.isInPlaylist(playlist, song)) "移出 " else "加入 ") + playlist + } + AlertDialog.Builder(this) + .setTitle("选择歌单") + .setItems( + items, + DialogInterface.OnClickListener { dialog: DialogInterface?, which: Int -> + val playlist = store.playlists.get(which) + val added = store.togglePlaylistSong(playlist, song) + saveState() + toast((if (added) "已加入:" else "已移出:") + playlist) + if ("歌单" == currentPage) showPlaylistSongs(playlist) + }) + .create().showForTv() + } + + companion object { + // ===== 歌曲源设备身份(用于 w.w345.my 等歌曲源的保活/认证伪装) ===== + // 歌曲源通过共享后台按 device 判定合法性, App 会向 hbeat.ktvsky.com + // 周期心跳(whoami)维持设备活跃;本 App 若不做心跳,设备可能被判为不合法而限流。 + // 设备标识与抓包请求一致(mac + sn 见 KtvJsBridge.DEVICE_MAC/DEVICE_SN)。 + private const val LEGACY_PACKAGE = "com.origjoy.local.ktv" + private const val LEGACY_DEVICE_ID = "ae60e9547958" // androidid / _mac + private const val LEGACY_KTV_ID = "10006789" // 门店/包厢 id + private const val HEARTBEAT_INTERVAL_MS = 3600_000L + + // ===== 主题色(对标原版麦动 配色) ===== + /** 主背景:极深蓝黑(原版 #080C12 系) */ + private val BG = Color.rgb(8, 12, 18) + + /** 面板背景:深蓝灰(原版卡片底色) */ + private val PANEL = Color.rgb(22, 31, 42) + + /** 次级面板:更深(列表区背景) */ + private val PANEL_2 = Color.rgb(15, 22, 31) + + /** 焦点色:青绿色(原版选中态) */ + private val FOCUS = Color.rgb(39, 204, 164) + + /** 金色:标题与高亮(原版 color_yellow_3 #FFE363 系) */ + private val GOLD = Color.rgb(245, 190, 89) + + /** 强调红:选中态/激活态(原版 color_red #FD3359) */ + private val ACCENT_RED = Color.rgb(253, 51, 89) + + /** 文本白:主文本色(原版 color_white_3 #F1F1F1) */ + private val TEXT_WHITE = Color.rgb(241, 241, 241) + + /** 次要文本:70% 透明白(原版 color_white_3_70) */ + private val TEXT_DIM = Color.argb(180, 241, 241, 241) + + /** 导航选中背景:半透明红 */ + private val NAV_SELECTED = Color.argb(50, 253, 51, 89) + + /** 分割线颜色 */ + private val DIVIDER = Color.argb(80, 241, 241, 241) + private const val PREFS = "ktv" + private const val KEY_CATALOG_URL = "catalog_url" + private const val KEY_SHOW_MOBILE_QR = "show_mobile_qr" + + private const val TAG = "MainActivity" + } +} diff --git a/app/src/main/java/com/local/ktv/MiliLog.kt b/app/src/main/java/com/local/ktv/MiliLog.kt new file mode 100644 index 0000000..362265b --- /dev/null +++ b/app/src/main/java/com/local/ktv/MiliLog.kt @@ -0,0 +1,201 @@ +package com.local.ktv + +import android.content.Context +import android.os.Build +import android.util.Log +import java.io.BufferedOutputStream +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Calendar +import java.util.Locale +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManagerFactory + +/** + * 远程日志客户端 —— 复刻为内网/私有部署的 syslog 服务器(zb.kasugano.cn:514)。 + * + * - 传输: TCP + TLS(信任打包的 raw/mili_log.crt 根证书, 服务器为 Let's Encrypt + * 签发链 ISRG Root X1, 故该根即可完成链校验; 连接目标域名与证书 SAN 不同, + * 属私有部署惯例, 因此只做链校验、不做主机名校验)。 + * - 格式: BSD syslog RFC 3164 —— MMM dd HH:mm:ss HOSTNAME TAG: message + * - 策略: 单后台线程 + 有界队列; 断线自动重连; 尽力而为, 失败不阻塞业务。 + * + * 日志事件: + * 1) App 启动时上报设备/网络信息; + * 2) 点歌解析歌曲源(w.w345.my / 实际下载域名)时实时上报; + * 3) 网络错误/接口 403/下载失败/播放失败实时上报。 + */ +object MiliLog { + private const val TAG = "MiliLog" + private const val HOST = "zb.kasugano.cn" + private const val PORT = 514 + private const val TAG_NAME = "MaidongKTV" + private const val FACILITY = 16 // local0 + private const val MAX_MSG_BYTES = 900 + private const val MAX_QUEUE = 300 + private const val CONNECT_TIMEOUT_MS = 8000 + private const val SOCKET_TIMEOUT_MS = 10000 + private const val RETRY_SLEEP_MS = 10_000L + + // RFC 3164 severity + private const val SEV_ERROR = 3 + private const val SEV_WARNING = 4 + private const val SEV_NOTICE = 5 + private const val SEV_INFO = 6 + + private val started = AtomicBoolean(false) + private val running = AtomicBoolean(false) + private val queue = LinkedBlockingQueue(MAX_QUEUE) + private var sslContext: SSLContext? = null + private var hostname = "android" + + @Volatile private var socket: SSLSocket? = null + private var senderThread: Thread? = null + + /** 用 app context 初始化(加载打包证书、读取设备标识)。可重复调用。 */ + fun init(context: Context) { + if (started.getAndSet(true)) return + val appContext = context.applicationContext + hostname = sanitize(Build.MODEL).ifEmpty { "android" } + sslContext = buildSslContext(appContext) + running.set(true) + senderThread = Thread({ sendLoop() }, "mili-syslog").also { it.isDaemon = true; it.start() } + } + + private fun sanitize(s: String): String = + s.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else ' ' } + .joinToString("").trim().replace(Regex("\\s+"), "-") + + private fun buildSslContext(context: Context): SSLContext? = try { + val cf = CertificateFactory.getInstance("X.509") + val cert = context.resources.openRawResource(R.raw.mili_log).use { + cf.generateCertificate(it) as X509Certificate + } + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null) + keyStore.setCertificateEntry("mili_log", cert) + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(keyStore) + SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) } + } catch (e: Throwable) { + Log.e(TAG, "buildSslContext failed", e) + null + } + + // ─── 对外上报接口 ──────────────────────────────── + + fun info(msg: String) = enqueue(SEV_INFO, msg) + fun notice(msg: String) = enqueue(SEV_NOTICE, msg) + fun warn(msg: String) = enqueue(SEV_WARNING, msg) + fun error(msg: String) = enqueue(SEV_ERROR, msg) + + private fun enqueue(severity: Int, msg: String) { + if (!started.get() || sslContext == null) return + val line = format(severity, msg) + if (line == null) return + // 队列满时丢最旧, 保证新日志能进队。 + if (!queue.offer(line)) { + queue.poll() + queue.offer(line) + } + } + + private fun format(severity: Int, msg: String): String? { + val body = msg.replace(Regex("[\\r\\n]+"), " ").trim() + if (body.isEmpty()) return null + val truncated = if (body.toByteArray(Charsets.UTF_8).size > MAX_MSG_BYTES) { + body.take(MAX_MSG_BYTES) + } else body + val now = Calendar.getInstance() + val months = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + val day = String.format(Locale.US, "%2d", now.get(Calendar.DAY_OF_MONTH)) + val time = String.format(Locale.US, "%02d:%02d:%02d", + now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND)) + val pri = FACILITY * 8 + severity + // RFC3164: TIMESTAMP HOSTNAME TAG: MSG (TAG 不含空格) + return "<$pri>${months[now.get(Calendar.MONTH)]} $day $time $hostname $TAG_NAME: $truncated" + } + + // ─── 发送线程 ──────────────────────────────────── + + private fun sendLoop() { + var consecutiveFailures = 0 + while (running.get()) { + val line: String = try { + queue.take() + } catch (e: InterruptedException) { + break + } + var sent = false + var attempt = 0 + while (!sent && attempt < 2) { + attempt++ + try { + val s = connectOrReuse() + val out = BufferedOutputStream(s.getOutputStream()) + out.write(line.toByteArray(Charsets.UTF_8)) + out.write('\n'.code) + out.flush() + sent = true + consecutiveFailures = 0 + // 同一连接顺手把队列里已积压的消息发完。 + var next: String? = queue.poll() + while (next != null) { + out.write(next.toByteArray(Charsets.UTF_8)) + out.write('\n'.code) + out.flush() + next = queue.poll() + } + } catch (e: Throwable) { + closeSocket() + if (attempt == 1) { + Log.w(TAG, "syslog send failed (${e.message}), retrying...") + try { Thread.sleep(1500) } catch (ie: InterruptedException) { break } + } + } + } + if (!sent) { + consecutiveFailures++ + Log.w(TAG, "syslog send dropped: $line") + if (consecutiveFailures >= 3) { + consecutiveFailures = 0 + try { Thread.sleep(RETRY_SLEEP_MS) } catch (ie: InterruptedException) { break } + } + } + } + closeSocket() + } + + private fun connectOrReuse(): SSLSocket { + val existing = socket + if (existing != null && existing.isConnected && !existing.isClosed && !existing.isOutputShutdown) { + return existing + } + val context = sslContext ?: throw IllegalStateException("SSL context not ready") + val raw = context.socketFactory.createSocket() as SSLSocket + raw.connect(java.net.InetSocketAddress(HOST, PORT), CONNECT_TIMEOUT_MS) + raw.soTimeout = SOCKET_TIMEOUT_MS + raw.startHandshake() + socket = raw + Log.i(TAG, "syslog connected to $HOST:$PORT") + return raw + } + + private fun closeSocket() { + try { socket?.close() } catch (ignored: Exception) {} + socket = null + } + + /** 供测试/关闭时调用。 */ + fun shutdown() { + running.set(false) + senderThread?.interrupt() + senderThread = null + closeSocket() + } +} diff --git a/app/src/main/java/com/local/ktv/MuseDatabase.kt b/app/src/main/java/com/local/ktv/MuseDatabase.kt new file mode 100644 index 0000000..fcf7108 --- /dev/null +++ b/app/src/main/java/com/local/ktv/MuseDatabase.kt @@ -0,0 +1,634 @@ +package com.local.ktv + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import android.util.Log +import java.io.File +import java.util.Locale + +class MuseDatabase @JvmOverloads constructor(initialSourceFile: File? = null) { + private var sourceFile: File? = initialSourceFile + private var database: SQLiteDatabase? = null + private var available = false + private var cdnPath = DEFAULT_CDN_PATH + + fun open(): Boolean { + if (database != null) return isAvailable() + val file = (sourceFile?.takeIf { it.exists() } ?: findDatabaseFile()) ?: return false + sourceFile = file + available = runCatching { + database = SQLiteDatabase.openDatabase( + file.absolutePath, + null, + SQLiteDatabase.OPEN_READONLY or SQLiteDatabase.NO_LOCALIZED_COLLATORS, + ) + database?.isOpen == true + }.onFailure { Log.e(TAG, "打开曲库数据库失败: ${file.absolutePath}", it) }.getOrDefault(false) + if (available) cdnPath = readCdnPath() + return available + } + + fun close() { + runCatching { database?.close() } + database = null + available = false + cdnPath = DEFAULT_CDN_PATH + } + + fun isAvailable(): Boolean = available && database?.isOpen == true + + fun dbFile(): File? = sourceFile + + fun songCount(): Int = count("SELECT COUNT(*) FROM songs WHERE deleted_at IS NULL") + + fun singerCount(): Int = count("SELECT COUNT(*) FROM singers WHERE deleted_at IS NULL AND name != ''") + + fun getCloudUrl(musicno: String): String? { + // 官方曲库无 cloud_url 冗余列;歌曲下载地址由 JS 桥(w.w345.my)实时解析。 + return null + } + + fun searchSongs(keyword: String?, offset: Int, limit: Int, language: String? = null): MutableList { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return mutableListOf() + val args = mutableListOf() + val condition = songKeywordCondition(query, args) + val languageCondition = if (!language.isNullOrEmpty() && language != "全部") { + args += language + " AND s.lang=?" + } else { + "" + } + args += limit.toString() + args += offset.toString() + return songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL " + + "AND $condition$languageCondition " + + "$RECOMMEND_ORDER, s.name ASC LIMIT ? OFFSET ?", + args.toTypedArray(), + ) + } + + fun searchSongCount(keyword: String?, language: String? = null): Int { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return 0 + val args = mutableListOf() + val condition = songKeywordCondition(query, args, tableAlias = "") + val languageCondition = if (!language.isNullOrEmpty() && language != "全部") { + args += language + " AND lang=?" + } else { + "" + } + // 命中数达上限即提前终止扫描,避免为分页显示对整个歌曲表做完整 COUNT。 + args += SEARCH_COUNT_CAP.toString() + return count( + "SELECT COUNT(*) FROM (SELECT 1 FROM songs WHERE deleted_at IS NULL " + + "AND $condition$languageCondition LIMIT ?)", + args.toTypedArray(), + ) + } + + fun searchSongsBySingerKeyword(keyword: String?, offset: Int, limit: Int): MutableList { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return mutableListOf() + val singerArgs = mutableListOf() + val singerCondition = textKeywordCondition( + query, + "sg.", + listOf("name", "name_cap", "name_full", "name_trim"), + singerArgs, + ) + return songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL AND EXISTS (" + + "SELECT 1 FROM song_singer_relations ssr " + + "INNER JOIN singers sg ON sg.id=ssr.singer_id " + + "WHERE ssr.song_id=s.id AND sg.deleted_at IS NULL AND $singerCondition) " + + "$RECOMMEND_ORDER LIMIT ? OFFSET ?", + (singerArgs + limit.toString() + offset.toString()).toTypedArray(), + ) + } + + fun searchSongsBySingerKeywordCount(keyword: String?): Int { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return 0 + val singerArgs = mutableListOf() + val singerCondition = textKeywordCondition( + query, + "sg.", + listOf("name", "name_cap", "name_full", "name_trim"), + singerArgs, + ) + return count( + "SELECT COUNT(*) FROM songs s WHERE s.deleted_at IS NULL AND EXISTS (" + + "SELECT 1 FROM song_singer_relations ssr " + + "INNER JOIN singers sg ON sg.id=ssr.singer_id " + + "WHERE ssr.song_id=s.id AND sg.deleted_at IS NULL AND $singerCondition)", + singerArgs.toTypedArray(), + ) + } + + fun searchSingers( + keyword: String?, + area: String?, + type: String?, + offset: Int, + limit: Int, + ): MutableList> { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return singers(area, type, offset, limit) + val args = mutableListOf() + val condition = textKeywordCondition(query, "", listOf("name", "name_cap", "name_full", "name_trim"), args) + val filters = StringBuilder() + if (!area.isNullOrEmpty()) { + filters.append(" AND area=?") + args += area + } + if (!type.isNullOrEmpty()) { + filters.append(" AND type=?") + args += type + } + args += limit.toString() + args += offset.toString() + return rows( + "SELECT id, name, type, area, hot_score, image FROM singers " + + "WHERE deleted_at IS NULL AND name != '' AND name != '#0000FF' AND $condition$filters " + + "ORDER BY local_hot_score DESC, hot_score DESC, name ASC LIMIT ? OFFSET ?", + args.toTypedArray(), + ) { cursor -> + arrayOf( + cursor.getString(0), cursor.getString(1), cursor.getString(2), cursor.getString(3), + cursor.getInt(4).toString(), resolveSingerImageUrl(cursor.getString(5)), + ) + } + } + + fun searchSingerCount(keyword: String?, area: String?, type: String?): Int { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return singerCount(area, type) + val args = mutableListOf() + val condition = textKeywordCondition(query, "", listOf("name", "name_cap", "name_full", "name_trim"), args) + val filters = StringBuilder() + if (!area.isNullOrEmpty()) { + filters.append(" AND area=?") + args += area + } + if (!type.isNullOrEmpty()) { + filters.append(" AND type=?") + args += type + } + return count( + "SELECT COUNT(*) FROM singers WHERE deleted_at IS NULL AND name != '' AND name != '#0000FF' " + + "AND $condition$filters", + args.toTypedArray(), + ) + } + + fun hotSongs(offset: Int, limit: Int): MutableList = songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL $RECOMMEND_ORDER LIMIT ? OFFSET ?", + pageArgs(limit, offset), + ) + + fun hdSongs(offset: Int, limit: Int): MutableList = songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL AND s.name LIKE '%(HD)%' " + + "$RECOMMEND_ORDER LIMIT ? OFFSET ?", + pageArgs(limit, offset), + ) + + fun quickSongs(limit: Int): MutableList = songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL LIMIT ?", + arrayOf(limit.toString()), + ) + + fun songsByLanguage(language: String?, offset: Int, limit: Int): MutableList { + if (language.isNullOrEmpty() || language == "全部") return hotSongs(offset, limit) + return songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL AND s.lang=? $RECOMMEND_ORDER LIMIT ? OFFSET ?", + arrayOf(language, limit.toString(), offset.toString()), + ) + } + + fun countByLanguage(language: String?): Int = if (language.isNullOrEmpty() || language == "全部") { + songCount() + } else { + count( + "SELECT COUNT(*) FROM songs WHERE deleted_at IS NULL AND lang=?", + arrayOf(language), + ) + } + + fun songsByWordCount(wordCount: Int, offset: Int, limit: Int): MutableList { + if (wordCount <= 0) return hotSongs(offset, limit) + val condition = if (wordCount >= 6) "s.name_len >= 6" else "s.name_len = $wordCount" + return songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL AND $condition $RECOMMEND_ORDER LIMIT ? OFFSET ?", + pageArgs(limit, offset), + ) + } + + fun countByWordCount(wordCount: Int): Int { + if (wordCount <= 0) return songCount() + val condition = if (wordCount >= 6) "name_len >= 6" else "name_len = $wordCount" + return count("SELECT COUNT(*) FROM songs WHERE deleted_at IS NULL AND $condition") + } + + fun languages(): MutableList = stringRows( + "SELECT name FROM song_langs WHERE deleted_at IS NULL ORDER BY sort_no ASC", + ) + + fun singers(area: String?, type: String?, offset: Int, limit: Int): MutableList> { + val args = mutableListOf() + val sql = StringBuilder( + "SELECT id, name, type, area, hot_score, image FROM singers " + + "WHERE deleted_at IS NULL AND name != '' AND name != '#0000FF' ", + ) + appendSingerFilters(sql, args, area, type) + sql.append("ORDER BY local_hot_score DESC, hot_score DESC, name ASC LIMIT ? OFFSET ?") + args += limit.toString() + args += offset.toString() + return rows(sql.toString(), args.toTypedArray()) { cursor -> + arrayOf( + cursor.getString(0), cursor.getString(1), cursor.getString(2), cursor.getString(3), + cursor.getInt(4).toString(), resolveSingerImageUrl(cursor.getString(5)), + ) + } + } + + fun singerCount(area: String?, type: String?): Int { + val args = mutableListOf() + val sql = StringBuilder( + "SELECT COUNT(*) FROM singers WHERE deleted_at IS NULL AND name != '' AND name != '#0000FF' ", + ) + appendSingerFilters(sql, args, area, type) + return count(sql.toString(), args.toTypedArray()) + } + + fun songsBySinger(singerId: String, offset: Int, limit: Int): MutableList = songs( + "$SONG_SELECT INNER JOIN song_singer_relations ssr ON s.id=ssr.song_id " + + "WHERE ssr.singer_id=? AND s.deleted_at IS NULL " + + "$RECOMMEND_ORDER LIMIT ? OFFSET ?", + arrayOf(singerId, limit.toString(), offset.toString()), + ) + + fun countSongsBySinger(singerId: String): Int = count( + "SELECT COUNT(*) FROM song_singer_relations ssr INNER JOIN songs s ON s.id=ssr.song_id " + + "WHERE ssr.singer_id=? AND s.deleted_at IS NULL", + arrayOf(singerId), + ) + + fun searchSongsBySinger( + singerId: String, + keyword: String?, + offset: Int, + limit: Int, + ): MutableList { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return songsBySinger(singerId, offset, limit) + val args = mutableListOf(singerId) + val condition = songKeywordCondition(query, args) + args += limit.toString() + args += offset.toString() + return songs( + "$SONG_SELECT INNER JOIN song_singer_relations ssr ON s.id=ssr.song_id " + + "WHERE ssr.singer_id=? AND s.deleted_at IS NULL AND $condition " + + "$RECOMMEND_ORDER LIMIT ? OFFSET ?", + args.toTypedArray(), + ) + } + + fun countSearchSongsBySinger(singerId: String, keyword: String?): Int { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return countSongsBySinger(singerId) + val args = mutableListOf(singerId) + val condition = songKeywordCondition(query, args) + return count( + "SELECT COUNT(*) FROM songs s INNER JOIN song_singer_relations ssr ON s.id=ssr.song_id " + + "WHERE ssr.singer_id=? AND s.deleted_at IS NULL AND $condition", + args.toTypedArray(), + ) + } + + fun songsBySingerName(singerName: String, offset: Int, limit: Int): MutableList { + val pattern = "%$singerName%" + return songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL " + + "AND s.id IN (SELECT ssr.song_id FROM song_singer_relations ssr " + + "INNER JOIN singers sg ON ssr.singer_id=sg.id WHERE sg.name LIKE ?) " + + "$RECOMMEND_ORDER LIMIT ? OFFSET ?", + arrayOf(pattern, limit.toString(), offset.toString()), + ) + } + + fun countSongsBySingerName(singerName: String): Int { + val pattern = "%$singerName%" + return count( + "SELECT COUNT(*) FROM songs s WHERE s.deleted_at IS NULL " + + "AND s.id IN (SELECT ssr.song_id FROM song_singer_relations ssr " + + "INNER JOIN singers sg ON ssr.singer_id=sg.id WHERE sg.name LIKE ?)", + arrayOf(pattern), + ) + } + + fun playlists(offset: Int, limit: Int): MutableList> = rows( + "SELECT id, name, song_count, hot_score FROM playlists WHERE deleted_at IS NULL " + + "ORDER BY hot_score DESC LIMIT ? OFFSET ?", + pageArgs(limit, offset), + ) { cursor -> + arrayOf(cursor.getString(0), cursor.getString(1), cursor.getInt(2).toString(), cursor.getLong(3).toString()) + } + + fun rankPlaylists(): MutableList> = rows( + "SELECT id, name, rank_type, song_count FROM playlists " + + "WHERE deleted_at IS NULL AND type=2 " + + "ORDER BY CASE WHEN rank_type IS NULL THEN 1 ELSE 0 END, rank_type ASC, rec_score DESC", + ) { cursor -> + arrayOf(cursor.getString(0), cursor.getString(1), cursor.getInt(2).toString(), cursor.getInt(3).toString()) + } + + fun rankSongs(playlistId: String, offset: Int, limit: Int): MutableList = + songsInPlaylist(playlistId, offset, limit) + + fun rankSongCount(playlistId: String): Int = count( + "SELECT COUNT(*) FROM playlist_songs ps INNER JOIN songs s ON s.id=ps.song_id " + + "WHERE ps.playlist_id=? AND s.deleted_at IS NULL", + arrayOf(playlistId), + ) + + fun playlistSongCount(playlistId: String): Int = rankSongCount(playlistId) + + fun categoryPlaylists(keyword: String?, offset: Int, limit: Int): MutableList> { + val query = keyword?.trim().orEmpty() + val args = mutableListOf() + val searchCondition = if (query.isEmpty()) { + "" + } else { + " AND " + textKeywordCondition(query, "", listOf("name", "name_cap", "name_full"), args) + } + args += limit.toString() + args += offset.toString() + return rows( + "SELECT id, name, song_count, image FROM playlists " + + "WHERE deleted_at IS NULL AND type=1$searchCondition " + + "ORDER BY CASE WHEN rec_score IS NULL THEN 1 ELSE 0 END, rec_score DESC, hot_score DESC " + + "LIMIT ? OFFSET ?", + args.toTypedArray(), + ) { cursor -> + arrayOf(cursor.getString(0), cursor.getString(1), cursor.getInt(2).toString(), cursor.getString(3)) + } + } + + fun categoryPlaylistCount(keyword: String?): Int { + val query = keyword?.trim().orEmpty() + val args = mutableListOf() + val searchCondition = if (query.isEmpty()) { + "" + } else { + " AND " + textKeywordCondition(query, "", listOf("name", "name_cap", "name_full"), args) + } + return count( + "SELECT COUNT(*) FROM playlists WHERE deleted_at IS NULL AND type=1$searchCondition", + args.toTypedArray(), + ) + } + + fun songsInPlaylist(playlistId: String, offset: Int, limit: Int): MutableList = songs( + "$SONG_SELECT INNER JOIN playlist_songs ps ON s.id=ps.song_id " + + "WHERE ps.playlist_id=? AND s.deleted_at IS NULL " + + "ORDER BY ps.sort_no ASC LIMIT ? OFFSET ?", + arrayOf(playlistId, limit.toString(), offset.toString()), + ) + + fun searchSongsInPlaylist( + playlistId: String, + keyword: String?, + offset: Int, + limit: Int, + ): MutableList { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return songsInPlaylist(playlistId, offset, limit) + val args = mutableListOf(playlistId) + val condition = songKeywordCondition(query, args) + args += limit.toString() + args += offset.toString() + return songs( + "$SONG_SELECT INNER JOIN playlist_songs ps ON s.id=ps.song_id " + + "WHERE ps.playlist_id=? AND s.deleted_at IS NULL AND $condition " + + "ORDER BY ps.sort_no ASC LIMIT ? OFFSET ?", + args.toTypedArray(), + ) + } + + fun countSearchSongsInPlaylist(playlistId: String, keyword: String?): Int { + val query = keyword?.trim().orEmpty() + if (query.isEmpty()) return playlistSongCount(playlistId) + val args = mutableListOf(playlistId) + val condition = songKeywordCondition(query, args) + return count( + "SELECT COUNT(*) FROM songs s INNER JOIN playlist_songs ps ON s.id=ps.song_id " + + "WHERE ps.playlist_id=? AND s.deleted_at IS NULL AND $condition", + args.toTypedArray(), + ) + } + + fun songById(songId: String?): Song? { + if (songId == null) return null + return songs( + "$SONG_SELECT WHERE s.id=? AND s.deleted_at IS NULL LIMIT 1", + arrayOf(songId), + ).firstOrNull() + } + + fun songsByFilenames(filenames: Collection): MutableList { + if (filenames.isEmpty()) return mutableListOf() + val result = mutableListOf() + filenames.distinct().chunked(400).forEach { chunk -> + val placeholders = List(chunk.size) { "?" }.joinToString(",") + result += songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL AND s.filename IN ($placeholders)", + chunk.toTypedArray(), + ) + } + return result + } + + /** Songs imported or downloaded by earlier app versions keep their directory in songs.path. */ + fun localPathSongs(): MutableList = songs( + "$SONG_SELECT WHERE s.deleted_at IS NULL " + + "AND s.path IS NOT NULL AND TRIM(s.path) != ''", + ) + + private fun songs(sql: String, args: Array? = null): MutableList = rows(sql, args, ::songFromCursor) + + private fun songFromCursor(cursor: Cursor): Song = Song().apply { + dbId = cursor.getString(0) + title = cursor.getString(1) + pinyin = cursor.getString(2) + pinyinFull = cursor.getString(3) + language = cursor.getString(4) + filename = cursor.getString(5) + dbPath = cursor.getString(6) + accomp = cursor.getString(7)?.trim()?.toIntOrNull() ?: 0 + hotScore = cursor.getInt(8) + localHotScore = cursor.getInt(9) + lyricPath = cursor.getString(10) + originalPath = cursor.getString(11) + // Column 12 mirrors the numeric accomp mode in this schema, not a file path. + accompanyPath = "" + pOrigin = cursor.getString(13) + pAccomp = cursor.getString(14) + singerNames = cursor.getString(15) + oModeChannels = cursor.getString(16) + remote = false + id = dbId + singer = singerNames ?: "未知歌手" + category = language ?: "国语" + playCount = if (localHotScore > 0) localHotScore else hotScore + path = resolveSongFilePath(this) + } + + private fun count(sql: String, args: Array? = null): Int { + val db = database?.takeIf { isAvailable() } ?: return 0 + return runCatching { + db.rawQuery(sql, args).use { cursor -> if (cursor.moveToFirst()) cursor.getInt(0) else 0 } + }.getOrDefault(0) + } + + private fun stringRows(sql: String): MutableList = + rows(sql) { it.getString(0) }.filterNotNull().filter(String::isNotEmpty).toMutableList() + + private fun rows(sql: String, args: Array? = null, mapper: (Cursor) -> T): MutableList { + val db = database?.takeIf { isAvailable() } ?: return mutableListOf() + return runCatching { + db.rawQuery(sql, args).use { cursor -> + buildList { + while (cursor.moveToNext()) add(mapper(cursor)) + }.toMutableList() + } + }.onFailure { Log.e(TAG, "曲库查询失败", it) }.getOrDefault(mutableListOf()) + } + + private fun appendSingerFilters(sql: StringBuilder, args: MutableList, area: String?, type: String?) { + if (!area.isNullOrEmpty() && area != "全部") { + sql.append("AND area=? ") + args += area + } + if (!type.isNullOrEmpty() && type != "全部") { + sql.append("AND type=? ") + args += type + } + } + + private fun songKeywordCondition( + query: String, + args: MutableList, + tableAlias: String = "s.", + ): String = textKeywordCondition( + query, + tableAlias, + listOf("name", "name_cap", "name_full", "name_trim"), + args, + ) + + private fun textKeywordCondition( + query: String, + tableAlias: String, + columns: List, + args: MutableList, + ): String { + val latinQuery = query.all { it.code < 128 } + return if (latinQuery) { + // 拼音/英文:仅用前缀 GLOB 命中索引列(name_cap 首字母 / name_full 全拼)。 + // 之前对 name/name_trim 也做 GLOB 会退化成 67 万行全表扫描,导致检索极慢。 + val pattern = query.uppercase(Locale.ROOT).replace("[", "[[]") + "*" + val indexed = columns.filter { it == "name_cap" || it == "name_full" }.ifEmpty { columns } + repeat(indexed.size) { args += pattern } + indexed.joinToString(prefix = "(", postfix = ")", separator = " OR ") { + "$tableAlias$it GLOB ?" + } + } else { + // 中文:LIKE '%..%' 本就走全表扫描,这里只在中文列(name_trim / name)上匹配, + // 去掉拼音列可显著减少每行的比较次数。 + val pattern = "%$query%" + val textColumns = columns.filter { it == "name_trim" || it == "name" }.ifEmpty { columns } + repeat(textColumns.size) { args += pattern } + textColumns.joinToString(prefix = "(", postfix = ")", separator = " OR ") { + "$tableAlias$it LIKE ?" + } + } + } + + private fun readCdnPath(): String { + val db = database ?: return DEFAULT_CDN_PATH + return runCatching { + db.rawQuery( + "SELECT cdn_path FROM global_confs WHERE cdn_path IS NOT NULL AND TRIM(cdn_path) != '' LIMIT 1", + null, + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else DEFAULT_CDN_PATH + } + }.getOrDefault(DEFAULT_CDN_PATH).trimEnd('/') + "/" + } + + private fun resolveSingerImageUrl(image: String?): String? { + val value = image?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (value.startsWith("/")) return value + val url = if (value.startsWith("http://") || value.startsWith("https://")) { + value + } else { + cdnPath + Uri.encode(value, "/") + } + return if ('?' in url) url else "$url?imageView2/1/w/100/h/100/q/95!/format/webp" + } + + companion object { + private const val TAG = "MuseDatabase" + private const val DEFAULT_CDN_PATH = "https://pub.mcdn.cherryonline.cn/" + private const val DEFAULT_DB_PATH = "${AppPaths.ROOT_PATH}/database/muse.db" + const val PAGE_SIZE = 10 + /** 搜索结果条数统计上限:达到该值即视为“足够多”,终止 COUNT 扫描以加速检索。 */ + const val SEARCH_COUNT_CAP = 2000 + const val VIDEO_ROOT = AppPaths.VIDEO_PATH + const val CLOUD_SONG_DIR = "cloud-song" + const val LOCAL_SONG_DIR = "local" + + private const val SONG_SELECT = + "SELECT s.id, s.name, s.name_cap, s.name_full, s.lang, s.filename, " + + "s.path, s.accomp, s.hot_score, s.local_hot_score, s.lyrics, " + + "s.original, s.accomp as accomp_path, '' as p_origin, '' as p_accomp, " + + "(SELECT group_concat(sg.name,'、') FROM song_singer_relations ssr " + + "INNER JOIN singers sg ON sg.id=ssr.singer_id WHERE ssr.song_id=s.id) AS singer_names, " + + "s.o_mode_channels_str FROM songs s " + // Matches the source database's rec/local-hot/hot composite indexes. DESC already puts NULL last. + private const val RECOMMEND_ORDER = + "ORDER BY s.rec_score DESC, s.local_hot_score DESC, s.hot_score DESC" + + @JvmStatic + fun resolveSongFilePath(song: Song?): String { + if (song == null) return "" + song.dbPath?.takeIf(String::isNotEmpty)?.let { path -> + val relative = path.trimStart('/') + return File(File(AppPaths.root, relative), song.filename.orEmpty()).absolutePath + } + return song.filename?.takeIf(String::isNotEmpty)?.let { filename -> + File(AppPaths.cloudSongsDir, filename).absolutePath + }.orEmpty() + } + + @JvmStatic + fun getStandardLocalPath(filename: String?): String = + filename?.takeIf(String::isNotEmpty)?.let { "$VIDEO_ROOT/$CLOUD_SONG_DIR/$it" }.orEmpty() + + @JvmStatic + fun songDirectories(): List = listOf(AppPaths.cloudSongsDir) + + @JvmStatic + fun defaultDbFile(): File = File(DEFAULT_DB_PATH) + + private fun findDatabaseFile(): File? { + return File(DEFAULT_DB_PATH).takeIf { it.exists() && it.canRead() } + } + + private fun pageArgs(limit: Int, offset: Int) = arrayOf(limit.toString(), offset.toString()) + } +} diff --git a/app/src/main/java/com/local/ktv/MuseDbSync.kt b/app/src/main/java/com/local/ktv/MuseDbSync.kt new file mode 100644 index 0000000..12d8d72 --- /dev/null +++ b/app/src/main/java/com/local/ktv/MuseDbSync.kt @@ -0,0 +1,236 @@ +package com.local.ktv + +import android.util.Base64 +import android.util.Log +import org.json.JSONObject +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.spec.X509EncodedKeySpec +import java.util.Locale +import java.util.zip.GZIPInputStream +import javax.crypto.Cipher + +/** + * 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。 + * + * 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的 Gitee 分片方案。 + * 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。 + */ +object MuseDbSync { + private const val TAG = "MuseDbSync" + private const val LOGIN_URL = "https://mconn.cherryonline.cn/mls-api/v1/login" + private const val SYNC_URL = "https://mconn.cherryonline.cn/mls-api/v1/sqlite/sync" + private const val RSA_PUBLIC_KEY = + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB" + private const val MUSIC_FILE_TYPE = 214 + private const val CHANNEL = "common" + private const val MODE = "ott" + private const val IP = "172.16.32.15" + + private data class DbFile(val url: String, val md5: String, val version: String) + + private val target: File get() = MuseDatabase.defaultDbFile() + + // 原版「时光KTV」mls-api 登录使用的设备标识(已验证:用该 id 从任意网络 + // 调 sqlite/sync 都能返回文件;随机 id 或 b 前缀的 mad-api box id 均返回空)。 + // 注意 muse_open=0 并不阻止下载,真正门槛是 mls 登录的 device_id。 + private val deviceId: String = "s00e04b021b2d" + + /** 取远端曲库版本;失败返回 null。版本取自 files[0].date(毫秒时间戳)。 */ + fun fetchVersion(): String? = runCatching { + val cookie = login() ?: return null + val file = sync(cookie) ?: return null + file.version + }.getOrNull() + + /** 下载并安装曲库。失败抛异常,由调用方决定是否回退到其它源。 */ + fun download(onProgress: (Int) -> Unit): File { + val cookie = login() ?: error("登录曲库服务失败") + val file = sync(cookie) ?: error("曲库同步失败或无可用文件") + val version = file.version + + val gzFile = File(target.parentFile, "${target.name}.gz.download") + val tmpDb = File(target.parentFile, "${target.name}.download") + try { + downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80)) } + onProgress(80) + if (file.md5.isNotEmpty()) { + check(md5(gzFile).equals(file.md5, ignoreCase = true)) { "曲库文件校验失败" } + } + onProgress(84) + GZIPInputStream(gzFile.inputStream().buffered(256 * 1024)).use { gz -> + BufferedOutputStream(FileOutputStream(tmpDb)).use { out -> + val buffer = ByteArray(256 * 1024) + while (true) { + val count = gz.read(buffer) + if (count < 0) break + out.write(buffer, 0, count) + } + } + } + onProgress(96) + val backup = File(target.parentFile, "${target.name}.backup") + backup.delete() + if (target.exists()) check(target.renameTo(backup)) { "无法备份旧曲库" } + if (!tmpDb.renameTo(target)) { + backup.renameTo(target) + error("无法安装下载的曲库") + } + backup.delete() + saveVersion(version) + onProgress(100) + return target + } finally { + gzFile.delete() + } + } + + private fun saveVersion(version: String) { + runCatching { File(target.parentFile, "db_version.txt").writeText(version) } + } + + private fun login(): String? { + val encrypted = rsaEncrypt(RSA_PUBLIC_KEY, """{"device_id":"$deviceId"}""") + val body = JSONObject() + .put("encrypted_data", encrypted) + .put("ip", IP) + .put("dns", "") + .put("router", "") + .put("subnet_mask", "255.255.255.0") + .put("channel", CHANNEL) + .put("mode", MODE) + .toString() + return postForCookie(LOGIN_URL, body) + } + + private fun sync(cookie: String): DbFile? { + val body = JSONObject() + .put("last_sync_at", 0) + .put("music_file_type", MUSIC_FILE_TYPE) + .toString() + val resp = postJson(SYNC_URL, body, cookie) ?: return null + val files = resp.optJSONArray("files") ?: return null + if (files.length() == 0) return null + val f = files.getJSONObject(0) + val url = f.optString("url") + if (url.isBlank()) return null + val version = f.optLong("date", 0L).takeIf { it > 0 }?.toString() + ?: f.optString("day").takeIf(String::isNotBlank) + ?: f.optLong("db_vc", 0L).takeIf { it > 0 }?.toString() + ?: "0" + return DbFile(url, f.optString("md5"), version) + } + + /** POST 并捕获 Set-Cookie 中的 muse-portal-connector。 */ + private fun postForCookie(url: String, jsonBody: String): String? { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 15_000 + readTimeout = 30_000 + doOutput = true + instanceFollowRedirects = true + setRequestProperty("Content-Type", "application/json; charset=UTF-8") + setRequestProperty("Accept", "*/*") + setRequestProperty("User-Agent", "MaidongKTV/1.2") + } + conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) } + if (conn.responseCode !in 200..299) return null + conn.inputStream.use { it.readBytes() } + conn.getHeaderField("Set-Cookie") + ?.substringBefore(';') + ?.takeIf { it.startsWith("muse-portal-connector=") } + } catch (error: Throwable) { + Log.w(TAG, "login failed: ${error.message}") + null + } finally { + conn?.disconnect() + } + } + + private fun postJson(url: String, jsonBody: String, cookie: String): JSONObject? { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 15_000 + readTimeout = 30_000 + doOutput = true + instanceFollowRedirects = true + setRequestProperty("Content-Type", "application/json; charset=UTF-8") + setRequestProperty("Accept", "*/*") + setRequestProperty("Cookie", cookie) + setRequestProperty("User-Agent", "MaidongKTV/1.2") + } + conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) } + if (conn.responseCode !in 200..299) return null + val text = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + JSONObject(text) + } catch (error: Throwable) { + Log.w(TAG, "sync failed: ${error.message}") + null + } finally { + conn?.disconnect() + } + } + + private fun downloadFile(url: String, dest: File, onProgress: (Int) -> Unit) { + var conn: HttpURLConnection? = null + try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 30_000 + readTimeout = 120_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", "MaidongKTV/1.2") + setRequestProperty("Accept", "*/*") + } + val code = conn.responseCode + check(code in 200..299) { "HTTP $code" } + val total = conn.contentLengthLong + conn.inputStream.buffered(256 * 1024).use { input -> + dest.outputStream().buffered(256 * 1024).use { out -> + val buffer = ByteArray(256 * 1024) + var read = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + out.write(buffer, 0, count) + read += count + if (total > 0) onProgress(((read * 100) / total).toInt().coerceIn(0, 99)) + } + } + } + } finally { + conn?.disconnect() + } + } + + private fun rsaEncrypt(publicKeyBase64: String, plaintext: String): String = runCatching { + val keyBytes = Base64.decode(publicKeyBase64.replace("\\s".toRegex(), ""), Base64.DEFAULT) + val publicKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(keyBytes)) + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + Base64.encodeToString(cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) + }.getOrDefault("") + + private fun md5(file: File): String { + val digest = MessageDigest.getInstance("MD5") + file.inputStream().buffered(256 * 1024).use { input -> + val buffer = ByteArray(256 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02X".format(Locale.ROOT, it.toInt() and 0xff) } + } +} diff --git a/app/src/main/java/com/local/ktv/PlaylistGridAdapter.kt b/app/src/main/java/com/local/ktv/PlaylistGridAdapter.kt new file mode 100644 index 0000000..217ba48 --- /dev/null +++ b/app/src/main/java/com/local/ktv/PlaylistGridAdapter.kt @@ -0,0 +1,89 @@ +package com.local.ktv + +import android.content.Context +import android.graphics.Color +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import kotlin.math.ceil + +class PlaylistGridAdapter( + private val context: Context, + private val playlists: List>, + private val onPlaylistClick: (Array) -> Unit, +) : BaseAdapter() { + private val density = context.resources.displayMetrics.density + + override fun getCount(): Int = ceil(playlists.size / COLUMN_COUNT.toDouble()).toInt() + override fun getItem(position: Int): Any = position + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val row = (convertView as? LinearLayout) ?: LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + } + row.removeAllViews() + repeat(COLUMN_COUNT) { column -> + val playlist = playlists.getOrNull(position * COLUMN_COUNT + column) + row.addView( + playlist?.let(::createTile) ?: View(context), + LinearLayout.LayoutParams(0, dp(116), 1f).apply { + setMargins(dp(3), dp(3), dp(3), dp(3)) + }, + ) + } + return row + } + + private fun createTile(playlist: Array): View = FrameLayout(context).apply { + setBackgroundResource(R.drawable.ott_bg_rank_item_not_selected) + isClickable = true + isFocusable = true + val name = playlist.getOrNull(1).orEmpty() + setAccessibleFocus( + "focus:playlist:${playlist.getOrNull(0) ?: name}", + "歌单,$name", + ) + + addView(ImageView(context).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + setImageResource(R.drawable.ic_default_playlist_avatar) + scaleType = ImageView.ScaleType.CENTER_INSIDE + alpha = 0.9f + }, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(82), Gravity.TOP)) + + addView(TextView(context).apply { + text = playlist.getOrNull(1).orEmpty() + textSize = 15f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + maxLines = 2 + setBackgroundResource(R.drawable.ott_bg_playlist_name) + setPadding(dp(4), 0, dp(4), 0) + }, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(34), Gravity.BOTTOM)) + + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> view.animate().scaleX(0.96f).scaleY(0.96f).alpha(0.86f).setDuration(70L).start() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + view.animate().scaleX(1f).scaleY(1f).alpha(1f).setDuration(120L).start() + } + false + } + setOnClickListener { onPlaylistClick(playlist) } + TvFocusStyler.install(this) + } + + private fun dp(value: Int): Int = (value * density + 0.5f).toInt() + + companion object { + private const val COLUMN_COUNT = 4 + } +} diff --git a/app/src/main/java/com/local/ktv/QuickSearchAdapter.kt b/app/src/main/java/com/local/ktv/QuickSearchAdapter.kt new file mode 100644 index 0000000..fad44a6 --- /dev/null +++ b/app/src/main/java/com/local/ktv/QuickSearchAdapter.kt @@ -0,0 +1,142 @@ +package com.local.ktv + +import android.content.Context +import android.graphics.Color +import android.graphics.Outline +import android.text.TextUtils +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.BaseAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import kotlin.math.ceil + +class QuickSearchAdapter( + private val context: Context, + private val singers: List>, + private val songs: List, + private val onSingerClick: (Array) -> Unit, + private val onSongClick: (Song) -> Unit, + private val onSongMore: (Song) -> Unit, +) : BaseAdapter() { + private val density = context.resources.displayMetrics.density + private val songRows = ceil(songs.size / 2.0).toInt() + + override fun getCount(): Int = (if (singers.isEmpty()) 0 else 1) + songRows + override fun getItem(position: Int): Any = position + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + if (singers.isNotEmpty() && position == 0) return createSingerRow() + val songRow = position - if (singers.isEmpty()) 0 else 1 + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + repeat(2) { column -> + val song = songs.getOrNull(songRow * 2 + column) + addView( + song?.let(::createSongCell) ?: View(context), + LinearLayout.LayoutParams(0, dp(60), 1f).apply { + setMargins(dp(3), dp(2), dp(3), dp(2)) + }, + ) + } + } + } + + private fun createSingerRow(): View = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + singers.take(4).forEach { singer -> + addView(createSingerTile(singer), LinearLayout.LayoutParams(0, dp(132), 1f)) + } + repeat((4 - singers.size.coerceAtMost(4)).coerceAtLeast(0)) { + addView(View(context), LinearLayout.LayoutParams(0, dp(132), 1f)) + } + } + + private fun createSingerTile(singer: Array): View = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + isClickable = true + isFocusable = true + val name = singer.getOrNull(1).orEmpty() + setAccessibleFocus("focus:singer:${singer.getOrNull(0) ?: name}", "歌星,$name") + addView(ImageView(context).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + SingerAvatarLoader.load(this, singer.getOrNull(5), R.drawable.ic_singer_placeholder) + scaleType = ImageView.ScaleType.CENTER_CROP + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) = outline.setOval(0, 0, view.width, view.height) + } + clipToOutline = true + }, LinearLayout.LayoutParams(dp(88), dp(88)).apply { topMargin = dp(5) }) + addView(TextView(context).apply { + text = name + textSize = 15f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(32))) + installPress(this) + setOnClickListener { onSingerClick(singer) } + TvFocusStyler.install(this) + } + + private fun createSongCell(song: Song): View = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(3), dp(2), dp(3)) + setBackgroundResource(R.drawable.ott_bg_item) + isClickable = true + isFocusable = true + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + val marker = "focus:song:${song.id ?: song.dbId ?: song.filename ?: "${song.title}|${song.singer}"}" + setAccessibleFocus(marker, "歌曲,${song.title.orEmpty()},歌手,${song.singer.orEmpty()},双击点歌,长按查看更多") + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_VERTICAL + addView(TextView(context).apply { + text = song.title + textSize = 16f + setTextColor(Color.WHITE) + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + addView(TextView(context).apply { + text = song.singer + textSize = 12f + setTextColor(Color.rgb(193, 193, 193)) + isSingleLine = true + ellipsize = TextUtils.TruncateAt.END + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)) + addView(ImageView(context).apply { + setImageResource(R.drawable.ott_ic_more) + setPadding(dp(8), dp(8), dp(8), dp(8)) + isClickable = false + isFocusable = false + }, LinearLayout.LayoutParams(dp(40), dp(40))) + installPress(this) + setOnClickListener { onSongClick(song) } + setOnLongClickListener { onSongMore(song); true } + TvFocusStyler.install(this) + } + + private fun installPress(view: View) { + view.setOnTouchListener { target, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> target.animate().scaleX(0.97f).scaleY(0.97f).alpha(0.86f).setDuration(70L).start() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + target.animate().scaleX(1f).scaleY(1f).alpha(1f).setDuration(120L).start() + } + false + } + } + + private fun dp(value: Int): Int = (value * density + 0.5f).toInt() +} diff --git a/app/src/main/java/com/local/ktv/SettingsListAdapter.kt b/app/src/main/java/com/local/ktv/SettingsListAdapter.kt new file mode 100644 index 0000000..37ac921 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SettingsListAdapter.kt @@ -0,0 +1,151 @@ +package com.local.ktv + +import android.content.Context +import android.graphics.Color +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.TextView +import android.widget.Switch + +data class SettingsEntry( + val icon: Int, + val title: String, + val subtitle: String = "", + val actionText: String = "设置", + val checked: Boolean? = null, + val action: () -> Unit, +) + +class SettingsListAdapter( + private val context: Context, + private val entries: List, + private val topFocusId: Int = View.NO_ID, +) : BaseAdapter() { + private val density = context.resources.displayMetrics.density + private val actionIds = IntArray(entries.size) { View.generateViewId() } + val firstActionId: Int + get() = actionIds.firstOrNull() ?: View.NO_ID + + override fun getCount(): Int = entries.size + override fun getItem(position: Int): Any = entries[position] + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val entry = entries[position] + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(20), 0, dp(20), 0) + setBackgroundResource(R.drawable.ott_bg_item) + isFocusable = false + isClickable = false + descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + + addView(ImageView(context).apply { + setImageResource(entry.icon) + scaleType = ImageView.ScaleType.CENTER_INSIDE + }, LinearLayout.LayoutParams(dp(28), dp(28))) + + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_VERTICAL + addView(label(entry.title, 16, Color.WHITE), LinearLayout.LayoutParams(-1, 0, 1f)) + if (entry.subtitle.isNotEmpty()) { + addView(label(entry.subtitle, 14, Color.rgb(193, 193, 193)), LinearLayout.LayoutParams(-1, 0, 1f)) + } + }, LinearLayout.LayoutParams(0, dp(72), 1f).apply { marginStart = dp(10) }) + + if (entry.checked != null) { + addView(Switch(context).apply { + id = actionIds[position] + nextFocusUpId = if (position == 0 && topFocusId != View.NO_ID) { + topFocusId + } else { + actionIds[(position - 1).coerceAtLeast(0)] + } + nextFocusDownId = actionIds[(position + 1).coerceAtMost(entries.lastIndex)] + isChecked = entry.checked + showText = false + setAccessibleFocus( + "focus:settings:${entry.title}", + "${entry.title}${entry.subtitle.takeIf(String::isNotEmpty)?.let { ",$it" }.orEmpty()},${if (entry.checked) "已开启" else "已关闭"}", + ) + isFocusable = true + isFocusableInTouchMode = false + setOnClickListener { + entry.action() + post { requestFocus() } + } + TvFocusStyler.installAction(this) + }, LinearLayout.LayoutParams(dp(70), dp(42))) + } else { + addView(label(entry.actionText, 16, Color.WHITE).apply { + id = actionIds[position] + nextFocusUpId = if (position == 0 && topFocusId != View.NO_ID) { + topFocusId + } else { + actionIds[(position - 1).coerceAtLeast(0)] + } + nextFocusDownId = actionIds[(position + 1).coerceAtMost(entries.lastIndex)] + setAccessibleFocus( + "focus:settings:${entry.title}", + "${entry.title}${entry.subtitle.takeIf(String::isNotEmpty)?.let { ",$it" }.orEmpty()},${entry.actionText}", + ) + gravity = Gravity.CENTER + setBackgroundResource(R.drawable.bg_btn_glass) + isFocusable = true + isFocusableInTouchMode = false + isClickable = true + setOnClickListener { entry.action() } + TvFocusStyler.install(this) + }, LinearLayout.LayoutParams(dp(130), dp(33))) + } + } + } + + fun requestInitialFocus(list: ListView, onTargetReady: (View) -> Unit = {}) { + requestFocusAt(list, 0, onTargetReady) + } + + fun positionForMarker(marker: String): Int? { + val prefix = "focus:settings:" + if (!marker.startsWith(prefix)) return null + val title = marker.removePrefix(prefix) + return entries.indexOfFirst { it.title == title }.takeIf { it >= 0 } + } + + fun requestFocusAt(list: ListView, position: Int, onTargetReady: (View) -> Unit = {}) { + if (position !in entries.indices) return + list.itemsCanFocus = true + list.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + list.setSelection(position) + list.post { + val rowIndex = position - list.firstVisiblePosition + val row = list.getChildAt(rowIndex) as? ViewGroup + row?.getChildAt(row.childCount - 1)?.let { + onTargetReady(it) + if (it.requestFocus() || it.requestFocusFromTouch()) { + it.refreshDrawableState() + it.jumpDrawablesToCurrentState() + it.invalidate() + } + } + } + } + + private fun label(value: String, size: Int, color: Int) = TextView(context).apply { + text = value + textSize = size.toFloat() + setTextColor(color) + gravity = Gravity.CENTER_VERTICAL + isSingleLine = true + ellipsize = android.text.TextUtils.TruncateAt.END + } + + private fun dp(value: Int): Int = (value * density + 0.5f).toInt() +} diff --git a/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt b/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt new file mode 100644 index 0000000..f0adb39 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SingerAvatarLoader.kt @@ -0,0 +1,112 @@ +package com.local.ktv + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Handler +import android.os.Looper +import android.util.LruCache +import android.widget.ImageView +import java.io.File +import java.io.FileOutputStream +import java.lang.ref.WeakReference +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * Singer-image loader following the original app's Glide behavior: recycled-view protection, + * memory and disk cache, request coalescing and sampled decoding instead of full-size bitmaps. + */ +object SingerAvatarLoader { + private const val MAX_DOWNLOAD_BYTES = 8L * 1024L * 1024L + private val executor = Executors.newFixedThreadPool(4) + private val main = Handler(Looper.getMainLooper()) + private val memory = object : LruCache(32 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024 + } + private val waiting = ConcurrentHashMap>>() + + fun load(view: ImageView, imageUrl: String?, placeholder: Int) { + val key = imageUrl.orEmpty() + view.tag = key + view.setImageResource(placeholder) + if (key.isEmpty()) return + memory.get(key)?.let { bitmap -> + view.setImageBitmap(bitmap) + return + } + + val waiters = waiting.computeIfAbsent(key) { CopyOnWriteArrayList() } + waiters += WeakReference(view) + if (waiters.size > 1) return + val cacheDir = File(view.context.applicationContext.cacheDir, "singer_avatars").apply { mkdirs() } + executor.execute { + val bitmap = runCatching { decode(key, cacheDir) }.getOrNull() + if (bitmap != null) memory.put(key, bitmap) + val targets = waiting.remove(key).orEmpty() + main.post { + if (bitmap != null) targets.forEach { reference -> + reference.get()?.takeIf { it.tag == key }?.setImageBitmap(bitmap) + } + } + } + } + + private fun decode(value: String, cacheDir: File): Bitmap? { + if (value.startsWith("/")) return decodeSampled(File(value)) + val cacheFile = File(cacheDir, sha256(value) + ".img") + if (cacheFile.isFile && cacheFile.length() > 0) { + decodeSampled(cacheFile)?.let { return it } + cacheFile.delete() + } + val temporary = File(cacheDir, cacheFile.name + ".tmp") + val connection = URL(value).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 4_000 + connection.readTimeout = 8_000 + connection.instanceFollowRedirects = true + connection.setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}") + require(connection.responseCode in 200..299) + require(connection.contentLengthLong <= 0 || connection.contentLengthLong <= MAX_DOWNLOAD_BYTES) + var total = 0L + connection.inputStream.buffered().use { input -> + FileOutputStream(temporary).buffered().use { output -> + val buffer = ByteArray(32 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + total += count + require(total <= MAX_DOWNLOAD_BYTES) + output.write(buffer, 0, count) + } + } + } + if (!temporary.renameTo(cacheFile)) { + temporary.copyTo(cacheFile, overwrite = true) + temporary.delete() + } + return decodeSampled(cacheFile) + } finally { + connection.disconnect() + temporary.delete() + } + } + + private fun decodeSampled(file: File): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (bounds.outWidth / sample > 256 || bounds.outHeight / sample > 256) sample *= 2 + return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { + inSampleSize = sample + inPreferredConfig = Bitmap.Config.RGB_565 + }) + } + + private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/local/ktv/SingerGridAdapter.kt b/app/src/main/java/com/local/ktv/SingerGridAdapter.kt new file mode 100644 index 0000000..308ed3b --- /dev/null +++ b/app/src/main/java/com/local/ktv/SingerGridAdapter.kt @@ -0,0 +1,97 @@ +package com.local.ktv + +import android.content.Context +import android.graphics.Color +import android.graphics.Outline +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.BaseAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import kotlin.math.ceil + +/** Four-column singer grid matching the original OTT singer page. */ +class SingerGridAdapter( + private val context: Context, + private val singers: List>, + private val onSingerClick: (Array) -> Unit, +) : BaseAdapter() { + private val density = context.resources.displayMetrics.density + + override fun getCount(): Int = ceil(singers.size / COLUMN_COUNT.toDouble()).toInt() + override fun getItem(position: Int): Any = position + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val row = (convertView as? LinearLayout) ?: LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(145)) + } + row.removeAllViews() + repeat(COLUMN_COUNT) { column -> + val index = position * COLUMN_COUNT + column + val singer = singers.getOrNull(index) + row.addView( + singer?.let(::createSingerTile) ?: View(context), + LinearLayout.LayoutParams(0, dp(145), 1f), + ) + } + return row + } + + private fun createSingerTile(singer: Array): View { + val name = singer.getOrNull(1).orEmpty() + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + isFocusable = true + isClickable = true + setAccessibleFocus("focus:singer:${singer.getOrNull(0) ?: name}", "歌星,$name") + + val avatar = ImageView(context).apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + scaleType = ImageView.ScaleType.CENTER_CROP + SingerAvatarLoader.load(this, singer.getOrNull(5), R.drawable.ic_singer_placeholder) + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setOval(0, 0, view.width, view.height) + } + } + clipToOutline = true + } + addView(avatar, LinearLayout.LayoutParams(dp(88), dp(88)).apply { topMargin = dp(21) }) + + addView(TextView(context).apply { + text = name + textSize = 16f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + isSingleLine = true + ellipsize = android.text.TextUtils.TruncateAt.END + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(30)).apply { topMargin = dp(4) }) + + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> view.animate().scaleX(0.94f).scaleY(0.94f).setDuration(90L).start() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + view.animate().scaleX(1f).scaleY(1f).setDuration(120L).start() + } + false + } + setOnClickListener { onSingerClick(singer) } + TvFocusStyler.install(this) + } + } + + private fun dp(value: Int): Int = (value * density + 0.5f).toInt() + + companion object { + private const val COLUMN_COUNT = 4 + } +} diff --git a/app/src/main/java/com/local/ktv/Song.kt b/app/src/main/java/com/local/ktv/Song.kt new file mode 100644 index 0000000..b109e3d --- /dev/null +++ b/app/src/main/java/com/local/ktv/Song.kt @@ -0,0 +1,242 @@ +package com.local.ktv + +import org.json.JSONObject +import java.io.File + +class Song { + @JvmField var id: String? = null + @JvmField var title: String? = null + @JvmField var singer: String? = null + @JvmField var category: String? = null + @JvmField var language: String? = null + @JvmField var pinyin: String? = null + @JvmField var album: String? = null + @JvmField var path: String? = null + @JvmField var downloadUrl: String? = null + @JvmField var videoUrl: String? = null + @JvmField var originalUrl: String? = null + @JvmField var accompanyUrl: String? = null + @JvmField var lyricUrl: String? = null + @JvmField var originalPath: String? = null + @JvmField var accompanyPath: String? = null + @JvmField var lyricPath: String? = null + @JvmField var remote = false + @JvmField var playCount = 0 + + @JvmField var dbId: String? = null + @JvmField var pinyinFull: String? = null + @JvmField var filename: String? = null + @JvmField var dbPath: String? = null + @JvmField var accomp = 0 + @JvmField var hotScore = 0 + @JvmField var localHotScore = 0 + @JvmField var singerNames: String? = null + @JvmField var pOrigin: String? = null + @JvmField var pAccomp: String? = null + @JvmField var oModeChannels: String? = null + + fun toJson(): JSONObject = JSONObject().apply { + put("id", id) + put("title", title) + put("singer", singer) + put("category", category) + put("language", language) + put("pinyin", pinyin) + put("album", album) + put("path", path) + put("downloadUrl", downloadUrl) + put("videoUrl", videoUrl) + put("originalUrl", originalUrl) + put("accompanyUrl", accompanyUrl) + put("lyricUrl", lyricUrl) + put("originalPath", originalPath) + put("accompanyPath", accompanyPath) + put("lyricPath", lyricPath) + put("remote", remote) + put("playCount", playCount) + dbId?.let { put("dbId", it) } + pinyinFull?.let { put("pinyinFull", it) } + filename?.let { put("filename", it) } + dbPath?.let { put("dbPath", it) } + if (accomp != 0) put("accomp", accomp) + if (hotScore != 0) put("hotScore", hotScore) + if (localHotScore != 0) put("localHotScore", localHotScore) + singerNames?.let { put("singerNames", it) } + pOrigin?.let { put("pOrigin", it) } + pAccomp?.let { put("pAccomp", it) } + oModeChannels?.let { put("oModeChannels", it) } + } + + fun displayTitle(): String = "${title.orEmpty()} - ${singer.orEmpty()}" + + fun hasLocalFile(): Boolean = path?.takeIf(String::isNotEmpty)?.let(::File)?.let { file -> + SongFileValidator.inspect(file, SongFileValidator.requiresTransportStream(file)).valid + } == true + + fun getDownloadUrl(): String { + downloadUrl?.takeIf(String::isNotEmpty)?.let { return it } + SongApiClient.getSongDownloadUrl(filename?.substringBeforeLast('.') ?: id)?.takeIf(String::isNotEmpty)?.let { + downloadUrl = it + return it + } + return filename?.takeIf(String::isNotEmpty) + ?.let { "https://pub.cdn.cherryonline.cn/video/cloud-song/$it" } + .orEmpty() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Song) return false + if (id != null && id == other.id) return true + if (filename != null && filename == other.filename) return true + if (path != null && path == other.path) return true + return title != null && title == other.title && singer != null && singer == other.singer + } + + override fun hashCode(): Int = id?.hashCode() ?: filename?.hashCode() ?: title?.hashCode() ?: 0 + + companion object { + @JvmStatic + fun local(path: String, name: String): Song = Song().apply { + val base = stripExt(name) + val parsed = parseName(base) + id = path + this.path = path + title = parsed.first + singer = parsed.second + val context = "$path $base" + category = inferCategory(context) + language = inferLanguage(context) + pinyin = initials("$title $singer") + album = "" + originalPath = existingSidecar(path, ".original.mp3", ".original.aac", ".original.m4a") + accompanyPath = existingSidecar(path, ".accompany.mp3", ".accompany.aac", ".accompany.m4a") + lyricPath = existingSidecar(path, ".lrc", ".ass", ".srt") + } + + @JvmStatic + fun remote(json: JSONObject): Song = Song().apply { + id = json.optString("id", json.optString("url")) + title = json.optString("title", json.optString("name", "未命名歌曲")) + singer = json.optString("singer", "网络曲库") + category = json.optString("category", "网络") + language = json.optString("language", "国语") + pinyin = json.optString("pinyin", initials(title.orEmpty())) + album = json.optString("album", "") + playCount = json.optInt("playCount", 0) + videoUrl = firstNonEmpty(json.optString("videoUrl"), json.optString("mvUrl"), json.optString("url"), json.optString("downloadUrl")) + downloadUrl = firstNonEmpty(json.optString("downloadUrl"), videoUrl) + originalUrl = firstNonEmpty(json.optString("originalUrl"), json.optString("vocalUrl"), json.optString("origUrl")) + accompanyUrl = firstNonEmpty(json.optString("accompanyUrl"), json.optString("accompanimentUrl"), json.optString("伴奏Url")) + lyricUrl = firstNonEmpty(json.optString("lyricUrl"), json.optString("lrcUrl"), json.optString("subtitleUrl")) + remote = true + } + + @JvmStatic + fun fromJson(json: JSONObject): Song = Song().apply { + id = json.optString("id") + title = json.optString("title", "未命名歌曲") + singer = json.optString("singer", "未知歌手") + category = json.optString("category", "其他") + language = json.optString("language", "国语") + pinyin = json.optString("pinyin", initials(title.orEmpty())) + album = json.optString("album", "") + path = json.optString("path", "") + downloadUrl = json.optString("downloadUrl", json.optString("url", "")) + videoUrl = json.optString("videoUrl", downloadUrl.orEmpty()) + originalUrl = json.optString("originalUrl", "") + accompanyUrl = json.optString("accompanyUrl", "") + lyricUrl = json.optString("lyricUrl", "") + originalPath = json.optString("originalPath", "") + accompanyPath = json.optString("accompanyPath", "") + lyricPath = json.optString("lyricPath", "") + remote = json.optBoolean("remote", false) + playCount = json.optInt("playCount", 0) + dbId = json.optNullableString("dbId") + pinyinFull = json.optNullableString("pinyinFull") + filename = json.optNullableString("filename") + dbPath = json.optNullableString("dbPath") + accomp = json.optInt("accomp", 0) + hotScore = json.optInt("hotScore", 0) + localHotScore = json.optInt("localHotScore", 0) + singerNames = json.optNullableString("singerNames") + pOrigin = json.optNullableString("pOrigin") + pAccomp = json.optNullableString("pAccomp") + oModeChannels = json.optNullableString("oModeChannels") + } + + private fun JSONObject.optNullableString(key: String): String? = + if (has(key) && !isNull(key)) optString(key) else null + + private fun stripExt(name: String): String = name.lastIndexOf('.').let { if (it > 0) name.substring(0, it) else name } + + private fun existingSidecar(mediaPath: String, vararg suffixes: String): String { + val base = stripExt(mediaPath) + return suffixes.asSequence().map { File(base + it) }.firstOrNull(File::exists)?.absolutePath.orEmpty() + } + + private fun firstNonEmpty(vararg values: String?): String = values.firstOrNull { !it.isNullOrBlank() }?.trim().orEmpty() + + private fun parseName(value: String): Pair { + var clean = value.replace(Regex("\\s+"), " ").trim() + clean = clean.replace(Regex("^[0-9]+[.、_\\- ]+"), "").trim() + for (separator in listOf(" - ", "-", "_", "-", "—", "–")) { + val index = clean.indexOf(separator) + if (index <= 0 || index >= clean.length - separator.length) continue + val first = clean.substring(0, index).trim() + val second = clean.substring(index + separator.length).trim() + if (first.isEmpty() || second.isEmpty()) continue + if (looksLikeSinger(first, second)) return second to first + if (looksLikeSinger(second, first)) return first to second + return if (first.length <= second.length) first to second else second to first + } + return (clean.ifEmpty { "未命名歌曲" }) to "本地歌曲" + } + + private fun looksLikeSinger(singer: String, title: String): Boolean { + val value = singer.lowercase() + if (containsAny(value, "群星", "合唱", "乐队", "组合")) return true + if (containsAny(value, "mv", "伴奏", "karaoke")) return false + return singer.length <= 8 && title.length >= 2 + } + + private fun inferLanguage(context: String): String { + val text = context.lowercase() + return when { + containsAny(text, "粤语", "广东", "香港", "cantonese") -> "粤语" + containsAny(text, "闽南", "台语", "福建", "hokkien") -> "闽南语" + containsAny(text, "英语", "英文", "欧美", "english", "us", "uk") -> "英语" + containsAny(text, "日语", "日本", "japanese") -> "日语" + containsAny(text, "韩语", "韩国", "korean") -> "韩语" + containsAny(text, "外语", "foreign") -> "外语" + else -> "国语" + } + } + + private fun inferCategory(context: String): String { + val text = context.lowercase() + return when { + containsAny(text, "dj", "舞曲", "disco", "慢摇", "热舞") -> "DJ舞曲" + containsAny(text, "儿歌", "儿童", "少儿") -> "儿歌" + containsAny(text, "戏曲", "京剧", "豫剧", "黄梅戏", "越剧") -> "戏曲" + containsAny(text, "民歌", "民族") -> "民歌" + containsAny(text, "军歌", "红歌") -> "红歌" + containsAny(text, "经典", "老歌", "怀旧") -> "经典老歌" + containsAny(text, "新歌", "流行", "pop") -> "流行" + containsAny(text, "粤语", "英语", "日语", "韩语", "闽南", "外语", "欧美", "日韩") -> "语种歌曲" + else -> "本地" + } + } + + private fun containsAny(text: String, vararg keys: String): Boolean = keys.any { text.contains(it.lowercase()) } + + private fun initials(value: String): String = buildString { + value.forEach { ch -> + when (ch) { + in 'A'..'Z', in '0'..'9' -> append(ch) + in 'a'..'z' -> append(ch.uppercaseChar()) + } + } + }.ifEmpty { value } + } +} diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt new file mode 100644 index 0000000..3d4269a --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -0,0 +1,111 @@ +package com.local.ktv + +import android.content.Context +import android.os.Looper +import android.util.Log +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * 歌曲 API 客户端 —— JS 热更新桥接 + * ================================= + * - 通过 KtvJsBridge 调用 Gitee 上的 JS 脚本 + * - API 变更时只需更新 Gitee JS 文件,App 无需发版 + * - 首次播放时实时获取 URL,不存在过期问题 + */ +object SongApiClient { + private const val TAG = "SongApiClient" + + private var bridge: KtvJsBridge? = null + @Volatile private var ready = false + + @JvmStatic + fun init(context: Context) { + if (bridge != null) return + bridge = KtvJsBridge(context) + bridge?.init { ok -> + ready = ok + Log.i(TAG, "JS Bridge init: $ok") + } + } + + /** 兼容旧接口 */ + @JvmStatic + fun init(sn: String, mac: String) { + // JS 脚本自己生成 MAC/SN + } + + @JvmStatic + fun getAuthToken(): String = "" + + @JvmStatic + fun getSongDownloadUrl(musicno: String?): String? = + musicno?.takeIf(String::isNotBlank)?.let { getSongDownloadUrl(it, "720", false) } + + @JvmStatic + fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? { + // 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。 + if (Looper.myLooper() == Looper.getMainLooper()) { + Log.w(TAG, "Synchronous JS request on main thread; using DB fallback") + return fetchFromDb(musicno) + } + val bridge = bridge ?: return fetchFromDb(musicno) + // 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备 + // (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。 + // 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。 + val deadline = System.currentTimeMillis() + 15_000L + while (!bridge.isReady() && System.currentTimeMillis() < deadline && !bridge.isDestroyed()) { + try { Thread.sleep(100) } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + break + } + } + if (!bridge.isReady()) { + Log.w(TAG, "Bridge not ready after wait, DB fallback") + return fetchFromDb(musicno) + } + val result = AtomicReference() + val latch = CountDownLatch(1) + bridge.getSongUrl(musicno, resolution, h265) { url -> result.set(url); latch.countDown() } + // JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。 + try { + latch.await(185, TimeUnit.SECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + val url = result.get() ?: fetchFromDb(musicno) + if (url != null) { + // 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。 + Eac19WindowGuard.noteUrlOk() + } + return url + } + + /** DB cloud_url fallback — 必须通过 demo 检测才能使用 */ + private fun fetchFromDb(musicno: String): String? { + try { + val db = MuseDatabase() + if (db.open()) { + val url = db.getCloudUrl(musicno) + db.close() + // DB 中的 cloud_url 可能也是 demo 地址, 做 URL 基本校验 + if (url != null && !isLikelyDemoUrl(url)) return url + } + } catch (_: Exception) {} + return null + } + + /** 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底) */ + private fun isLikelyDemoUrl(url: String): Boolean { + val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return true + // 纯 IP 地址 → 高概率 demo + if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"))) return true + // Cloudflare R2 demo bucket (pub-*.r2.dev) + if (host.matches(Regex("^pub-[a-z0-9]+\\.r2\\.dev$"))) return true + return false + } + + @JvmStatic + fun clearTokenCache() {} +} diff --git a/app/src/main/java/com/local/ktv/SongFileValidator.kt b/app/src/main/java/com/local/ktv/SongFileValidator.kt new file mode 100644 index 0000000..9286f94 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongFileValidator.kt @@ -0,0 +1,118 @@ +package com.local.ktv + +import java.io.File +import java.io.RandomAccessFile +import java.util.concurrent.ConcurrentHashMap + +/** Rejects complete-looking CDN previews that pass the old size-only check. */ +object SongFileValidator { + const val MIN_VALID_FILE_SIZE = 6L * 1024L * 1024L + const val MIN_SONG_DURATION_MS = 60_000L + private const val TS_PACKET_SIZE = 188 + private const val PROBE_PACKET_COUNT = 24_000 // about 4.3 MiB at each end + private const val PTS_WRAP = 1L shl 33 + + data class Result( + val valid: Boolean, + val durationMs: Long? = null, + val reason: String = "", + ) + + private data class Cached(val length: Long, val modified: Long, val result: Result) + private val cache = ConcurrentHashMap() + + fun inspect(file: File, requireTransportStream: Boolean = true): Result { + if (!file.isFile) return Result(false, reason = "file missing") + val key = file.absolutePath + cache[key]?.takeIf { it.length == file.length() && it.modified == file.lastModified() }?.let { + return it.result + } + val result = inspectUncached(file, requireTransportStream) + cache[key] = Cached(file.length(), file.lastModified(), result) + return result + } + + fun forget(file: File) { + cache.remove(file.absolutePath) + } + + fun requiresTransportStream(file: File): Boolean = + file.extension.equals("ts", true) || file.extension.equals("ls", true) + + private fun inspectUncached(file: File, requireTransportStream: Boolean): Result { + if (file.length() < MIN_VALID_FILE_SIZE) { + return Result(false, reason = "file too small: ${file.length()}") + } + RandomAccessFile(file, "r").use { input -> + val syncOffset = findSyncOffset(input) + if (syncOffset < 0) { + return if (requireTransportStream) Result(false, reason = "not an MPEG-TS file") else Result(true) + } + val packetCount = ((input.length() - syncOffset) / TS_PACKET_SIZE).coerceAtLeast(0) + if (packetCount < 10) return Result(false, reason = "incomplete MPEG-TS file") + val first = readPts(input, syncOffset, minOf(packetCount, PROBE_PACKET_COUNT.toLong()).toInt()) + val tailPackets = minOf(packetCount, PROBE_PACKET_COUNT.toLong()).toInt() + val tailOffset = syncOffset + (packetCount - tailPackets) * TS_PACKET_SIZE + val last = readPts(input, tailOffset, tailPackets) + if (first.isEmpty() || last.isEmpty()) { + // Some valid transport streams omit PTS in the sampled range; keep the old size check in that case. + return Result(true, reason = "duration unavailable") + } + val startPts = first.minOrNull() ?: return Result(true, reason = "duration unavailable") + val endPts = last.maxOrNull() ?: return Result(true, reason = "duration unavailable") + val ticks = if (endPts >= startPts) endPts - startPts else endPts + PTS_WRAP - startPts + val durationMs = ticks * 1_000L / 90_000L + return if (durationMs < MIN_SONG_DURATION_MS) { + Result(false, durationMs, "preview too short: ${durationMs}ms") + } else { + Result(true, durationMs) + } + } + } + + private fun findSyncOffset(input: RandomAccessFile): Long { + val probeSize = minOf(input.length(), TS_PACKET_SIZE.toLong() * 8).toInt() + if (probeSize < TS_PACKET_SIZE * 3) return -1 + val probe = ByteArray(probeSize) + input.seek(0) + input.readFully(probe) + for (offset in 0 until minOf(TS_PACKET_SIZE, probe.size)) { + var matches = 0 + var index = offset + while (index < probe.size && matches < 5) { + if (probe[index].toInt() and 0xff != 0x47) break + matches++ + index += TS_PACKET_SIZE + } + if (matches >= 3) return offset.toLong() + } + return -1 + } + + private fun readPts(input: RandomAccessFile, offset: Long, count: Int): List { + val values = ArrayList() + val packet = ByteArray(TS_PACKET_SIZE) + input.seek(offset) + repeat(count) { + if (input.read(packet) != TS_PACKET_SIZE) return@repeat + if ((packet[0].toInt() and 0xff) != 0x47 || (packet[1].toInt() and 0x40) == 0) return@repeat + val adaptationControl = packet[3].toInt().ushr(4) and 0x03 + if (adaptationControl != 1 && adaptationControl != 3) return@repeat + var payload = 4 + if (adaptationControl == 3) payload += 1 + (packet[4].toInt() and 0xff) + if (payload + 14 > TS_PACKET_SIZE) return@repeat + if (packet[payload].toInt() != 0 || packet[payload + 1].toInt() != 0 || + packet[payload + 2].toInt() and 0xff != 1 + ) return@repeat + if ((packet[payload + 7].toInt() and 0x80) == 0) return@repeat + val p = payload + 9 + val pts = (((packet[p].toLong() ushr 1) and 0x07) shl 30) or + ((packet[p + 1].toLong() and 0xff) shl 22) or + (((packet[p + 2].toLong() ushr 1) and 0x7f) shl 15) or + ((packet[p + 3].toLong() and 0xff) shl 7) or + ((packet[p + 4].toLong() ushr 1) and 0x7f) + values += pts + } + return values + } +} diff --git a/app/src/main/java/com/local/ktv/SongLibrary.kt b/app/src/main/java/com/local/ktv/SongLibrary.kt new file mode 100644 index 0000000..e9bf3c6 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongLibrary.kt @@ -0,0 +1,98 @@ +package com.local.ktv + +import org.json.JSONArray +import java.io.File +import java.util.Locale + +class SongLibrary { + @JvmField val rootDir = AppPaths.songsDir + @JvmField val importDir = AppPaths.importDir + private val indexFile = AppPaths.catalogFile + private val localSongs = mutableListOf() + private val remoteSongs = mutableListOf() + @JvmField val muse = MuseDatabase() + + fun ensureDirs() { + rootDir.mkdirs() + importDir.mkdirs() + indexFile.parentFile?.mkdirs() + } + + fun catalogFile(): File = indexFile + + fun scanLocal(): List { + ensureDirs() + localSongs.clear() + scanDir(rootDir) + scanDir(importDir) + File("/storage").listFiles().orEmpty() + .filterNot { it.name == "emulated" || it.name == "self" } + .forEach { root -> + listOf(File(root, "KTV"), File(root, "songs")) + .filter(File::exists) + .forEach(::scanDir) + } + localSongs.sortBy { it.title.orEmpty().lowercase(Locale.ROOT) } + return localSongs.toList() + } + + fun loadCachedRemote(): List { + remoteSongs.clear() + runCatching { + if (indexFile.exists()) { + val array = JSONArray(indexFile.readText()) + repeat(array.length()) { remoteSongs += Song.remote(array.getJSONObject(it)) } + } + } + return remoteSongs.toList() + } + + fun saveRemote(array: JSONArray) { + ensureDirs() + indexFile.writeText(array.toString(2)) + loadCachedRemote() + } + + fun clearRemoteCache(): Boolean { + remoteSongs.clear() + return indexFile.exists() && indexFile.delete() + } + + fun allSongs(): List = localSongs + remoteSongs + + fun categories(): List = linkedSetOf("全部").apply { + allSongs().forEach { add(it.category?.takeIf(String::isNotEmpty) ?: "其他") } + }.toList() + + fun languages(): List = linkedSetOf("全部").apply { + allSongs().forEach { add(it.language?.takeIf(String::isNotEmpty) ?: "其他") } + }.toList() + + fun singers(): List = linkedSetOf().apply { + allSongs().forEach { add(it.singer?.takeIf(String::isNotEmpty) ?: "未知歌手") } + }.toList() + + fun targetFor(song: Song): File { + val clean = "${song.singer}-${song.title}".replace(Regex("[\\\\/:*?\"<>|]"), "_") + val raw = song.downloadUrl.orEmpty() + val dot = raw.lastIndexOf('.') + val query = raw.indexOf('?', dot.coerceAtLeast(0)) + val candidate = if (dot >= 0) raw.substring(dot, if (query > dot) query else raw.length) else ".mp4" + val extension = candidate.takeIf { it.length <= 8 && '/' !in it } ?: ".mp4" + return File(rootDir, clean + extension) + } + + private fun scanDir(dir: File) { + dir.listFiles().orEmpty().forEach { file -> + when { + file.isDirectory -> scanDir(file) + isMedia(file.name) -> localSongs += Song.local(file.absolutePath, file.name) + } + } + } + + private fun isMedia(name: String): Boolean { + val lower = name.lowercase(Locale.ROOT) + return listOf(".mp4", ".mkv", ".avi", ".mp3", ".flac", ".wav").any(lower::endsWith) + } +} diff --git a/app/src/main/java/com/local/ktv/SongListAdapter.kt b/app/src/main/java/com/local/ktv/SongListAdapter.kt new file mode 100644 index 0000000..94700d5 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongListAdapter.kt @@ -0,0 +1,289 @@ +package com.local.ktv + +import android.content.Context +import android.graphics.Color +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import kotlin.math.ceil + +data class SongRowState( + val current: Boolean = false, + val queued: Boolean = false, + val local: Boolean = false, + val downloading: Boolean = false, + val failed: Boolean = false, + val paused: Boolean = false, + val progress: Int = 0, +) + +class SongListCallbacks( + val onPrimary: (Song) -> Unit, + val onDownload: (Song) -> Unit, + val onMore: (Song) -> Unit, + val onTop: (Song) -> Unit, + val onDelete: (Song) -> Unit, + val onPauseResume: (Song) -> Unit, +) + +/** Stateful row renderer based on the original OTT song/rank/order/download layouts. */ +class SongListAdapter( + private val context: Context, + private val songs: List, + private val mode: String, + private val pageOffset: Int, + private val previousPageFocusId: Int, + private val nextPageFocusId: Int, + private val stateFor: (Song) -> SongRowState, + private val callbacks: SongListCallbacks, +) : BaseAdapter() { + private val density = context.resources.displayMetrics.density + private val columns = if (mode in SINGLE_COLUMN_MODES) 1 else 2 + private val cardIds = HashMap() + + private fun cardId(index: Int): Int = cardIds.getOrPut(songKey(songs[index])) { View.generateViewId() } + + override fun getCount(): Int = ceil(songs.size / columns.toDouble()).toInt() + override fun getItem(position: Int): Any = position + override fun getItemId(position: Int): Long = songKey(songs[position * columns]).hashCode().toLong() + override fun hasStableIds(): Boolean = true + + fun matches(renderMode: String, renderPageOffset: Int): Boolean = + mode == renderMode && pageOffset == renderPageOffset + + fun adapterPositionFor(focusMarker: String): Int? { + val key = focusMarker.removePrefix(FOCUS_PREFIX) + val index = songs.indexOfFirst { songKey(it) == key } + return index.takeIf { it >= 0 }?.div(columns) + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + if (columns == 1) { + return createSingleRow(songs[position], position).apply { + if (position == count - 1) nextFocusDownId = previousPageFocusId + } + } + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val cells = mutableListOf() + repeat(2) { column -> + val index = position * 2 + column + val cell = songs.getOrNull(index)?.let { + createCatalogCell(it, index, column, position == count - 1) + } ?: View(context) + cells += cell + addView( + cell, + LinearLayout.LayoutParams(0, dp(60), 1f), + ) + } + } + } + + private fun createSingleRow(song: Song, index: Int): View = when (mode) { + "rank" -> createRankRow(song, index) + "ordered" -> createOrderedRow(song, index) + "downloads" -> createDownloadRow(song, index) + "sang" -> createSangRow(song, index) + else -> createCatalogCell(song, index, 0, index == count - 1) + } + + private fun createCatalogCell(song: Song, songIndex: Int, column: Int, lastRow: Boolean): View { + val state = stateFor(song) + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(4), 0, dp(4)) + setBackgroundResource(R.drawable.ott_bg_item) + val info = songInfo(song, stacked = true) + addView(info, LinearLayout.LayoutParams(0, dp(52), 1f)) + if (!state.local || state.queued) { + addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40))) + } + addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40))) + configureSongCard(this, song, songIndex) + if (songIndex >= columns) nextFocusUpId = cardId(songIndex - columns) + if (column == 1) nextFocusLeftId = cardId(songIndex - 1) + if (column == 0 && songIndex + 1 < songs.size) nextFocusRightId = cardId(songIndex + 1) + if (!lastRow && songIndex + columns < songs.size) nextFocusDownId = cardId(songIndex + columns) + if (lastRow) { + nextFocusDownId = if (column == 0) previousPageFocusId else nextPageFocusId + } + } + } + + private fun createRankRow(song: Song, index: Int): View { + val state = stateFor(song) + val order = pageOffset + index + 1 + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setBackgroundResource(R.drawable.ott_bg_item) + val badge = if (order <= 3) { + icon( + intArrayOf(R.drawable.ic_rank_order_1, R.drawable.ic_rank_order_2, R.drawable.ic_rank_order_3)[order - 1], + null, + ) + } else { + text(order.toString(), 16, Color.WHITE, Gravity.CENTER) + } + addView(badge, LinearLayout.LayoutParams(dp(47), dp(48))) + addView(songInfo(song, stacked = false), LinearLayout.LayoutParams(0, dp(48), 1f)) + if (!state.local || state.queued) { + addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40))) + } + addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40))) + configureSongCard(this, song, index) + } + } + + private fun createOrderedRow(song: Song, index: Int): View { + val state = stateFor(song) + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setBackgroundResource(R.drawable.ott_bg_download) + addView( + if (state.current) icon(R.drawable.ott_ic_playing, null) + else text((index + 1).toString(), 16, Color.WHITE, Gravity.CENTER), + LinearLayout.LayoutParams(dp(47), dp(48)), + ) + addView(songInfo(song, stacked = false), LinearLayout.LayoutParams(0, dp(48), 1f)) + if (!state.current) { + addView(icon(R.drawable.ic_add_to_top) { callbacks.onTop(song) }, LinearLayout.LayoutParams(dp(36), dp(36))) + addView(icon(R.drawable.ic_delete) { callbacks.onDelete(song) }, LinearLayout.LayoutParams(dp(36), dp(36))) + } + configureSongCard(this, song, index) + } + } + + private fun createDownloadRow(song: Song, index: Int): View { + val state = stateFor(song) + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setBackgroundResource(R.drawable.ott_bg_download) + addView(text((index + 1).toString(), 16, Color.WHITE, Gravity.CENTER), LinearLayout.LayoutParams(dp(47), dp(48))) + addView(songInfo(song, stacked = false), LinearLayout.LayoutParams(0, dp(48), 1f)) + if (state.downloading) { + addView(ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + progress = state.progress + progressDrawable = context.getDrawable(R.drawable.bg_download_progress) + }, LinearLayout.LayoutParams(dp(90), dp(8)).apply { marginEnd = dp(6) }) + } + when { + state.failed || state.paused -> addView( + icon(R.drawable.ic_download_retry) { callbacks.onPauseResume(song) }, + LinearLayout.LayoutParams(dp(36), dp(36)), + ) + state.downloading -> addView( + icon(R.drawable.ic_pause) { callbacks.onPauseResume(song) }, + LinearLayout.LayoutParams(dp(36), dp(36)), + ) + state.local -> addView( + icon(R.drawable.ott_ic_play) { callbacks.onPrimary(song) }, + LinearLayout.LayoutParams(dp(36), dp(36)), + ) + } + addView(icon(R.drawable.ic_delete) { callbacks.onDelete(song) }, LinearLayout.LayoutParams(dp(36), dp(36))) + configureSongCard(this, song, index) + } + } + + private fun createSangRow(song: Song, index: Int): View = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setBackgroundResource(R.drawable.ott_bg_item) + addView(text((index + 1).toString(), 16, Color.WHITE, Gravity.CENTER), LinearLayout.LayoutParams(dp(47), dp(48))) + addView(songInfo(song, stacked = false), LinearLayout.LayoutParams(0, dp(48), 1f)) + addView(icon(R.drawable.ic_order_song) { callbacks.onPrimary(song) }, LinearLayout.LayoutParams(dp(36), dp(36))) + configureSongCard(this, song, index) + } + + private fun songInfo(song: Song, stacked: Boolean): View = LinearLayout(context).apply { + orientation = if (stacked) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + if (stacked) { + addView(text(song.title.orEmpty(), 16, Color.WHITE, Gravity.START or Gravity.CENTER_VERTICAL), LinearLayout.LayoutParams(-1, 0, 1f)) + addView(text(song.singer.orEmpty(), 14, Color.rgb(193, 193, 193), Gravity.START or Gravity.CENTER_VERTICAL), LinearLayout.LayoutParams(-1, 0, 1f)) + } else { + addView(text(song.title.orEmpty(), 16, Color.WHITE, Gravity.START or Gravity.CENTER_VERTICAL), LinearLayout.LayoutParams(0, -1, 1f).apply { marginEnd = dp(12) }) + addView(text(song.singer.orEmpty(), 16, Color.rgb(193, 193, 193), Gravity.START or Gravity.CENTER_VERTICAL), LinearLayout.LayoutParams(0, -1, 1f)) + } + } + + private fun text(value: String, size: Int, color: Int, gravityValue: Int) = TextView(context).apply { + text = value + textSize = size.toFloat() + setTextColor(color) + gravity = gravityValue + isSingleLine = true + ellipsize = android.text.TextUtils.TruncateAt.END + } + + private fun icon(resource: Int, action: (() -> Unit)?): ImageView = ImageView(context).apply { + setImageResource(resource) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(dp(7), dp(7), dp(7), dp(7)) + isFocusable = action != null + isClickable = action != null + action?.let { callback -> + id = View.generateViewId() + installPressAnimation(this) + setOnClickListener { callback() } + } + } + + private fun configureSongCard(card: View, song: Song, songIndex: Int) { + card.id = cardId(songIndex) + val state = stateFor(song) + val stateText = when { + state.current -> "正在播放" + state.queued -> "已点" + state.downloading -> "正在下载百分之${state.progress}" + state.local -> "已下载" + else -> "未下载" + } + card.setAccessibleFocus( + FOCUS_PREFIX + songKey(song), + "歌曲,${song.title.orEmpty()},歌手,${song.singer.orEmpty()},$stateText,双击点歌,长按查看更多", + ) + (card as? ViewGroup)?.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + installPressAnimation(card) + card.setOnClickListener { callbacks.onPrimary(song) } + card.setOnLongClickListener { callbacks.onMore(song); true } + } + + private fun installPressAnimation(view: View) { + view.isFocusable = true + view.isFocusableInTouchMode = false + view.isClickable = true + TvFocusStyler.install(view) + view.setOnTouchListener { target, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> target.animate().scaleX(0.96f).scaleY(0.96f).alpha(0.82f).setDuration(80L).start() + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + target.animate().scaleX(1f).scaleY(1f).alpha(1f).setDuration(120L).start() + } + false + } + } + + private fun dp(value: Int): Int = (value * density + 0.5f).toInt() + + companion object { + const val FOCUS_PREFIX = "focus:song:" + private val SINGLE_COLUMN_MODES = setOf("rank", "ordered", "downloads", "sang") + + private fun songKey(song: Song): String = + (song.id ?: song.dbId ?: song.filename ?: "${song.title}|${song.singer}").toString() + } +} diff --git a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt new file mode 100644 index 0000000..22746f3 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt @@ -0,0 +1,378 @@ +package com.local.ktv + +import android.util.Log +import com.liulishuo.okdownload.DownloadTask as OkDownloadTask +import com.liulishuo.okdownload.OkDownload +import com.liulishuo.okdownload.core.breakpoint.BreakpointInfo +import com.liulishuo.okdownload.core.cause.EndCause +import com.liulishuo.okdownload.core.cause.ResumeFailedCause +import com.liulishuo.okdownload.core.listener.DownloadListener2 +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +object SongOkDownloadManager { + private const val TAG = "SongDownload" + const val MIN_VALID_FILE_SIZE = SongFileValidator.MIN_VALID_FILE_SIZE + private const val MAX_RETRY_COUNT = 3 + private const val STALL_TIMEOUT_MS = 25_000L + + interface DownloadCallback { + fun onDownloadStart(song: Song) + fun onDownloadProgress(song: Song, progress: Int) + fun onDownloadReadyToPlay(song: Song) + fun onDownloadComplete(song: Song, localPath: String) + fun onDownloadFailed(song: Song, error: String) + } + + private val tasks = ConcurrentHashMap() + private val pending = ConcurrentHashMap.newKeySet() + private val callbacks = ConcurrentHashMap() + private val progress = ConcurrentHashMap() + private val totalLengths = ConcurrentHashMap() + private val forcedRetryReasons = ConcurrentHashMap() + private val manuallyCanceled = ConcurrentHashMap.newKeySet() + private val io = Executors.newFixedThreadPool(4) + private val watchdog = Executors.newSingleThreadScheduledExecutor() + + @JvmStatic + fun getLocalFile(song: Song): File { + val fileName = song.filename?.takeIf(String::isNotEmpty) ?: "${song.id}.ts" + return File("${MuseDatabase.VIDEO_ROOT}/${MuseDatabase.CLOUD_SONG_DIR}", fileName) + } + + @JvmStatic + fun isDownloaded(song: Song): Boolean { + val file = getLocalFile(song) + if (file.exists()) { + val inspection = SongFileValidator.inspect(file, SongFileValidator.requiresTransportStream(file)) + if (!inspection.valid) { + Log.w(TAG, "Discarding invalid song file: ${file.absolutePath}, ${inspection.reason}") + SongFileValidator.forget(file) + file.delete() + song.path = null + } + } + val downloaded = file.exists() + if (downloaded) { + // Catalog rows may still contain their original cloud-songNNNN path. + // Once the unified app directory has a verified file, it is the only + // valid playback source regardless of the stale non-null catalog path. + song.path = file.absolutePath + } + return downloaded + } + + @JvmStatic + fun isDownloading(song: Song): Boolean { + val key = songKey(song) + return tasks.containsKey(key) || pending.contains(key) + } + + @JvmStatic + fun download(song: Song, callback: DownloadCallback?) { + if (isDownloaded(song)) { + val localFile = getLocalFile(song) + Log.i(TAG, "Using verified local file: ${localFile.absolutePath}, ${localFile.length()} bytes") + callback?.onDownloadComplete(song, localFile.absolutePath) + return + } + val key = songKey(song) + if (tasks.containsKey(key) || !pending.add(key)) { + callback?.onDownloadStart(song) + return + } + io.execute { + try { + var url = buildDownloadUrl(song) + if (url.isEmpty()) { + // 首次下载偶发解析不到地址(JS 桥冷启动/节点瞬时抖动)。短暂退避后重试一次, + // 复现“手动重试即恢复”的体验,避免第一首歌一次性报失败。 + Log.w(TAG, "First URL resolve failed for ${song.title}, retrying once") + Thread.sleep(1_200) + url = buildDownloadUrl(song, forceRefresh = true) + } + if (url.isEmpty()) { + pending.remove(key) + callback?.onDownloadFailed(song, "无法生成下载地址") + } else { + startDownloadTask(song, url, callback, 0) + } + } catch (error: Throwable) { + pending.remove(key) + callback?.onDownloadFailed(song, error.message ?: "下载初始化失败") + } + } + } + + @JvmStatic + fun cancelDownload(song: Song) { + val key = songKey(song) + pending.remove(key) + tasks.remove(key)?.let { task -> + manuallyCanceled.add(key) + task.cancel() + clearBrokenDownload(task, getLocalFile(song)) + } + callbacks.remove(key) + progress.remove(key) + totalLengths.remove(key) + } + + @JvmStatic + fun getDownloadProgress(song: Song): Int = progress[songKey(song)] ?: 0 + + private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String { + if (!forceRefresh) { + song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached -> + if (!AdVideoFilter.isAdVideo(cached)) return cached + // 缓存的下载地址是广告视频,清掉并重新解析(广告会被桥接层屏蔽/替换)。 + Log.w(TAG, "丢弃广告缓存地址: ${song.title}") + song.downloadUrl = null + } + } + val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id + return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url -> + // 请求源 = 配置的歌曲源(与 JS ktv_api.js CONFIG.HOSTS 同步)。 + val srcHost = "w.w345.my" + if (url.isNotEmpty()) { + song.downloadUrl = url + val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown" + MiliLog.info( + "点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=成功 urlHost=$cdnHost", + ) + } else { + Log.w(TAG, "Unable to get download URL: ${song.title}") + MiliLog.error( + "点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 详情=无法生成下载地址", + ) + } + } + } + + private fun startDownloadTask(song: Song, url: String, callback: DownloadCallback?, attempt: Int) { + val key = songKey(song) + val target = getLocalFile(song) + val partial = File(target.parentFile, "${target.name}.download") + target.parentFile?.mkdirs() + if (attempt > 0) partial.delete() + val task = OkDownloadTask.Builder(url, partial.parentFile!!) + .setFilename(partial.name) + .setPassIfAlreadyCompleted(false) + .setMinIntervalMillisCallbackProcess(250) + .build() + tasks[key] = task + pending.remove(key) + callback?.let { callbacks[key] = it } + progress[key] = 0 + totalLengths[key] = 0 + val downloaded = AtomicLong(0) + val lastActivityAt = AtomicLong(System.currentTimeMillis()) + var httpResponseCode = 0 + + scheduleStallCheck(song, task, lastActivityAt, attempt) + task.enqueue(object : DownloadListener2() { + override fun taskStart(task: OkDownloadTask) { + lastActivityAt.set(System.currentTimeMillis()) + callbacks[key]?.onDownloadStart(song) + } + + override fun connectEnd( + task: OkDownloadTask, + blockCount: Int, + responseCode: Int, + responseHeaderFields: MutableMap>, + ) { + httpResponseCode = responseCode + lastActivityAt.set(System.currentTimeMillis()) + } + + override fun downloadFromBeginning(task: OkDownloadTask, info: BreakpointInfo, cause: ResumeFailedCause) { + totalLengths[key] = info.totalLength + } + + override fun downloadFromBreakpoint(task: OkDownloadTask, info: BreakpointInfo) { + totalLengths[key] = info.totalLength + downloaded.set(info.totalOffset) + } + + override fun fetchProgress(task: OkDownloadTask, blockIndex: Int, increaseBytes: Long) { + if (increaseBytes > 0) lastActivityAt.set(System.currentTimeMillis()) + val total = totalLengths[key] ?: return + if (total <= 0) return + val value = (downloaded.addAndGet(increaseBytes) * 100 / total).toInt().coerceIn(0, 98) + progress[key] = value + callbacks[key]?.onDownloadProgress(song, value) + } + + override fun taskEnd(task: OkDownloadTask, cause: EndCause, realCause: Exception?) { + tasks.remove(key, task) + totalLengths.remove(key) + val cb = callbacks.remove(key) + val forcedReason = forcedRetryReasons.remove(key) + if (manuallyCanceled.remove(key)) return + if (cause != EndCause.COMPLETED) { + val retryable = forcedReason != null || cause != EndCause.CANCELED + if (retryable && attempt < MAX_RETRY_COUNT) { + clearBrokenDownload(task, target) + val response = if (httpResponseCode > 0) " HTTP $httpResponseCode" else "" + retry(song, cb, attempt + 1, forcedReason ?: "${cause.name}$response") + } else { + pending.remove(key) + progress.remove(key) + cb?.onDownloadFailed(song, cause.name + (realCause?.message?.let { ": $it" } ?: "")) + } + return + } + pending.add(key) + progress[key] = 99 + cb?.onDownloadProgress(song, 99) + io.execute { finalizeDownload(song, task, target, partial, cb, attempt) } + } + }) + } + + private fun finalizeDownload( + song: Song, + task: OkDownloadTask, + target: File, + partial: File, + callback: DownloadCallback?, + attempt: Int, + ) { + val key = songKey(song) + try { + var downloadedFile = task.file ?: partial + if (!downloadedFile.exists() || downloadedFile.length() < MIN_VALID_FILE_SIZE) { + val size = downloadedFile.takeIf(File::exists)?.length() ?: 0L + clearBrokenDownload(task, target) + retryOrFail(song, callback, attempt, "file too small: $size", "下载文件异常,已自动清理") + return + } + if (TsDecryptor.isEncrypted(downloadedFile)) { + val decrypted = File(downloadedFile.parentFile, "${downloadedFile.name}.decrypted") + decrypted.delete() + val decryptedOk = TsDecryptor.decryptFile(downloadedFile, decrypted) + val replaced = decryptedOk && downloadedFile.delete() && decrypted.renameTo(downloadedFile) + if (!replaced) { + clearBrokenDownload(task, target) + retryOrFail(song, callback, attempt, "decrypt failed", "歌曲解密失败,异常文件已删除") + return + } + downloadedFile = task.file ?: partial + } + val inspection = SongFileValidator.inspect( + downloadedFile, + SongFileValidator.requiresTransportStream(target), + ) + if (!inspection.valid) { + Log.w(TAG, "Rejecting downloaded media for ${song.title}: ${inspection.reason}") + SongFileValidator.forget(downloadedFile) + clearBrokenDownload(task, target) + pending.remove(key) + progress.remove(key) + val message = inspection.durationMs?.let { duration -> + "服务器返回试听片段(${duration / 1000}秒),未保存也未加入已点" + } ?: "下载文件不是有效歌曲视频,未保存也未加入已点" + callback?.onDownloadFailed(song, message) + return + } + if (target.exists() && !target.delete()) { + pending.remove(key) + progress.remove(key) + callback?.onDownloadFailed(song, "无法替换旧的歌曲文件") + return + } + if (!downloadedFile.renameTo(target)) { + clearBrokenDownload(task, target) + retryOrFail(song, callback, attempt, "rename failed", "下载文件落盘失败") + return + } + pending.remove(key) + progress.remove(key) + song.path = target.absolutePath + callback?.onDownloadProgress(song, 100) + callback?.onDownloadComplete(song, target.absolutePath) + } catch (error: Throwable) { + Log.e(TAG, "Finalizing download failed: ${song.title}", error) + clearBrokenDownload(task, target) + retryOrFail(song, callback, attempt, error.message ?: "finalize failed", "下载文件处理失败") + } + } + + private fun retryOrFail( + song: Song, + callback: DownloadCallback?, + attempt: Int, + reason: String, + terminalMessage: String, + ) { + if (attempt < MAX_RETRY_COUNT) { + retry(song, callback, attempt + 1, reason) + } else { + val key = songKey(song) + pending.remove(key) + progress.remove(key) + callback?.onDownloadFailed(song, terminalMessage) + } + } + + private fun scheduleStallCheck( + song: Song, + task: OkDownloadTask, + lastActivityAt: AtomicLong, + attempt: Int, + ) { + watchdog.schedule({ + val key = songKey(song) + if (tasks[key] !== task) return@schedule + val idleMs = System.currentTimeMillis() - lastActivityAt.get() + if (idleMs >= STALL_TIMEOUT_MS) { + Log.w(TAG, "Download stalled for ${idleMs}ms: ${song.title}, attempt=$attempt") + forcedRetryReasons[key] = "stalled for ${idleMs}ms" + task.cancel() + } else { + scheduleStallCheck(song, task, lastActivityAt, attempt) + } + }, STALL_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + private fun clearBrokenDownload(task: OkDownloadTask, target: File) { + runCatching { OkDownload.with().breakpointStore().remove(task.id) } + val partial = task.file ?: File(target.parentFile, "${target.name}.download") + if (partial.absolutePath != target.absolutePath) partial.delete() + File(partial.parentFile, "${partial.name}.decrypted").delete() + } + + private fun retry(song: Song, callback: DownloadCallback?, attempt: Int, reason: String) { + Log.w(TAG, "Retrying ${song.title}, attempt=$attempt, reason=$reason") + val key = songKey(song) + pending.add(key) + progress[key] = 0 + callback?.onDownloadProgress(song, 0) + song.downloadUrl = null + SongApiClient.clearTokenCache() + io.execute { + Thread.sleep((attempt * 750L).coerceAtMost(2_250L)) + val url = runCatching { buildDownloadUrl(song, forceRefresh = true) }.getOrDefault("") + if (url.isEmpty()) { + if (attempt < MAX_RETRY_COUNT) { + retry(song, callback, attempt + 1, "refresh download URL failed") + } else { + pending.remove(key) + progress.remove(key) + callback?.onDownloadFailed(song, "刷新下载地址失败") + } + } else { + startDownloadTask(song, url, callback, attempt) + } + } + } + + private fun songKey(song: Song): String = song.id?.takeIf(String::isNotEmpty) + ?: song.filename?.takeIf(String::isNotEmpty) + ?: song.title.orEmpty() + +} diff --git a/app/src/main/java/com/local/ktv/TsDecryptor.kt b/app/src/main/java/com/local/ktv/TsDecryptor.kt new file mode 100644 index 0000000..1033b5a --- /dev/null +++ b/app/src/main/java/com/local/ktv/TsDecryptor.kt @@ -0,0 +1,199 @@ +package com.local.ktv + +import android.util.Log +import java.io.BufferedInputStream +import java.io.File +import java.io.RandomAccessFile +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +object TsDecryptor { + private const val TAG = "TsDecryptor" + private const val HEADER_SIZE = 512 + private const val TS_PACKET_SIZE = 188 + private const val MAX_VALIDATION_PACKETS = 32 * 1024 + + private val signatures = listOf("THUNDERCRYP3", "HHCMUSECRYP1", "HHCMUSECRYP2") + .map { it.toByteArray(Charsets.US_ASCII) } + + @JvmStatic + fun isEncrypted(file: File?): Boolean { + if (file == null || !file.exists() || file.length() < HEADER_SIZE) return false + return runCatching { + RandomAccessFile(file, "r").use { input -> + if (input.readUnsignedByte() != 0x47) return@use true + val signature = ByteArray(12) + input.seek(500) + input.readFully(signature) + signatures.any(signature::contentEquals) + } + }.getOrDefault(false) + } + + @JvmStatic + fun decryptFile(inputFile: File, outputFile: File): Boolean { + if (!inputFile.exists() || inputFile.length() <= HEADER_SIZE) return false + return try { + RandomAccessFile(inputFile, "r").use { input -> + val header = ByteArray(HEADER_SIZE) + input.readFully(header) + val key = resolveKey(header) + val segmentSize = header.u8(452) * 1024 + val mode = header.u8(453) + val interval = header.u8(454) + val firstEncryptedSegment = header.u8(457) + require(segmentSize > 0) { "Invalid encrypted segment size" } + require(mode == 0) { "Unsupported Thunder encryption mode: $mode" } + + val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES")) + } + outputFile.parentFile?.mkdirs() + outputFile.outputStream().buffered(256 * 1024).use { output -> + val segment = ByteArray(segmentSize) + var segmentIndex = 0L + while (true) { + val count = input.readChunk(segment) + if (count <= 0) break + if (shouldDecrypt(segmentIndex, firstEncryptedSegment, interval)) { + val encryptedLength = count - count % 16 + if (encryptedLength > 0) { + val decrypted = cipher.doFinal(segment, 0, encryptedLength) + output.write(decrypted) + if (encryptedLength < count) { + output.write(segment, encryptedLength, count - encryptedLength) + } + } else { + output.write(segment, 0, count) + } + } else { + output.write(segment, 0, count) + } + segmentIndex++ + } + } + } + + check(outputFile.length() == inputFile.length() - HEADER_SIZE) { + "Decrypted file length mismatch" + } + check(isValidTransportStream(outputFile)) { "Decrypted TS validation failed" } + true + } catch (error: Throwable) { + Log.e(TAG, "TS decrypt failed: ${inputFile.absolutePath}", error) + outputFile.delete() + false + } + } + + private fun shouldDecrypt(segmentIndex: Long, firstEncryptedSegment: Int, interval: Int): Boolean { + if (interval == 0) return true + return segmentIndex == firstEncryptedSegment.toLong() || + (segmentIndex > firstEncryptedSegment && segmentIndex % interval == 1L) + } + + private fun resolveKey(header: ByteArray): ByteArray { + val signature = header.copyOfRange(500, 512) + val table = when { + signature.contentEquals(signatures[0]) -> THUNDER_KEYS + signature.contentEquals(signatures[1]) -> HHC1_KEYS + signature.contentEquals(signatures[2]) -> HHC2_KEYS + else -> error("Unknown Thunder encryption signature") + } + return table[header.u8(53) and 0x0f].hexToBytes() + } + + private fun isValidTransportStream(file: File): Boolean { + val packetCount = minOf(file.length() / TS_PACKET_SIZE, MAX_VALIDATION_PACKETS.toLong()) + if (packetCount == 0L) return false + BufferedInputStream(file.inputStream(), 256 * 1024).use { input -> + val packet = ByteArray(TS_PACKET_SIZE) + repeat(packetCount.toInt()) { + var read = 0 + while (read < packet.size) { + val count = input.read(packet, read, packet.size - read) + if (count < 0) return false + read += count + } + if (packet[0] != 0x47.toByte()) return false + } + } + return true + } + + private fun RandomAccessFile.readChunk(buffer: ByteArray): Int { + var total = 0 + while (total < buffer.size) { + val count = read(buffer, total, buffer.size - total) + if (count < 0) break + total += count + } + return total + } + + private fun ByteArray.u8(index: Int): Int = this[index].toInt() and 0xff + + private fun String.hexToBytes(): ByteArray { + require(length % 2 == 0) + return ByteArray(length / 2) { index -> + substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } + + private val THUNDER_KEYS = arrayOf( + "c6d3cdd0f1ebf5ded4d7d3cebbd3dad2cecdd0b8deaccbead2c5cac3c2dba6ac", + "f5deebd8d3ced7b9dad2d3d8d0bcd9f6cbd7c9c2cab1d0f0a6f8d0afd0d3b9d4", + "c3d4d7b2c2d5aaeecbd3b7dcbcc6b4f4b7d4b2b2fed5e0cbd3d5b2d7c6b7cef3", + "b6bdaea7ced2d2b4aac1bbd3cbc8b7d6aacbbdaecbd4cbb5f9daddc0cecbe4d7", + "d7d3d4dde8cedaf3c7b4cbddd2d3aee7f5d6d6b2d2aed0c9cbcddbb2ddf0e6c9", + "b4d3d3b0d9d6cbe2e2aeaea2d6b8c7b2c3c9e5bbd6d9c7bcaee2d2dababac1b2", + "d9d6cbe2e2aeaea2d6b2c7b2c3e0e5bbd6d9c7bcaee2d2dababad6b2d3d3b1bb", + "e2aeaea2d69dc7b2c35fe5bbd6d9c7bcaee2d2dababac2b2d3d3d9bbd6cbe2f0", + "97bbecd2fab782f0c1d0f6b3bab2c8a9dec7d0ec62fdf8b1d3d2cebccef5c4dd", + "eadcd4eecad6d3bec7ae81fde6badcd7eecf98d3c1f6d1cefac8d4c2b6d2c4c6", + "e7bed4c1b5fdd8bcbfd7d0c867d3cbcbd1d4d1d6d4d8e1c8c4c7d1d6eedee1c8", + "f2b1c8bdbbd4cfeff1b0d0f0d3cbddd4cec492d8d3c2b3e1daedb5fdb1bcf0d0", + "d3cdd2d6d2f5d1dc81c1b3ba90eec2ee81cecece90c5fdc4ceb2d4cdc4bbd5f5", + "f5cbaee0b9c9e8cafafae5bfcdcebccef5acc3c4b9d6bccdfadcc3f5bfd6b6d2", + "d6d3c3b7dcdad2f4b7d6b3c3fedca3f4baccd2c2eeecf3e3b7c3cabdfefcbfab", + "e0b4e4f8b8c9c9d3a3a5cfdad2cab5d2f3a6dbf3d6bfd2bfaecbcba5cec5bcc3", + ) + + private val HHC1_KEYS = arrayOf( + "d7cad2d3d3b1e0d0d4cfcbc5bbb0b5f3d1d6d4d7a7aec3d4b6b2bad4f8bbf5b6", + "d3cfd2d0b2b6d5d2bbf8dfb2babacebec3c3b4fdb7d7d6d7b8f7aed3c9c2d3ce", + "cec4babde1b1f5bbc9b6d3b6edf8ebf8ceb2c5b2aabbf3bbc8d6d3d0cbd2d1c5", + "f6f8dad0d4d0b6d3f2c5f8d0b5b7c7d3dcbad7e0bdb0c8c1f7aecaa6b6d6d0d4", + "d4b1d2d7bbd8d3d3ceced7b2b4bdd3bbd1d6d4d6a7aebbd8ced1bed4e1a7fdf2", + "d3d3d3eec7b9d6d2ddb1c1b2ced4d3b1cabbdad8d3b7cacedaf2c7c5d7d7b0c6", + "d6d7b9c3aed3dbbbc7d4c6b9f3bbe4dbd6b8d6c6aeb8bee4d3d4b8d0ebdab8d0", + "d0aabbaecbbad2d2f9cdd4e0b2b6c0b2bbf8f1bbd0babdbfd0cddac9d6b2d6d0", + "d7b1b0b6d3a5b2f8cabec3c9b3d3f4f7ceced3d3dededadac7c7cad1f3f3c2d4", + "f8c3d3c6c0c0b9c8d6f1b1e7b8d5d4c7bbdfbbd0b6d2cac8f8b2abe7bad7d4b4", + "d6bbd2d5aebcb2feb2b2d7d2bbbbd3d4bcd6d4b5baaabbc2d6c8cec6aacbaaa9", + "aee2c0ebd2b6d6d6d4f8aeaed0ced2d2ccded4d4c3b3b5c0f1dcc2f1c3b5c6d3", + "cbb4b2dcb3d3bbb2c6d0d3d7dfc4e2d3cacbbeceaef9d8cab6d3c3d0f8fbcfa2", + "aee1c0cfd2d6d6ced4aeaee4c0d2d2b2f1d4d4aecbc0c0cec0f1f1cad4bcc3d0", + "bebad0c4b4f5a2d1bad7d7d3ced3d3d0d2cfd4cad4c4bbc2b1cec9b5f0caabdc", + "bda2dee4d2bbd7cbe0d8d3f9d7d2d4d2e3b2bbd4d2b2cab9d4bbd3dbb7d3c6c6", + ) + + private val HHC2_KEYS = arrayOf( + "b1b4d6d6f8f3aeaed5cab5b5dfc2d8c0b9cbb4b2fac0e6bbd6c9cdbfaefaf6c9", + "c0dac9c9d5c9d3d3dfcfebebc1cdd6d6eeacaeaec3d2cbc9f1e2c0fad3bfbfb6", + "d5b5b7bddfc0b2abc7d6b4c4faf7cbaad6d3ceb2c6c3e5bbb9d2d5ced9b2dfc5", + "a8f8bfcdc1d6d7b7eedae4a3cacacacaebebebebd0c7c1c3d0bfb7f7b1cac9ce", + "ced7d5d6aaf4dfc6d6c6d2c8aee4f2a8cacdc0d2c6e2fbb2d2cab6b1d4c6f8f8", + "f8f8f8f8c8b1b1c4a1b8dcd3d6d6d6d6aeaeaeaecac7c5b1b5bfadb0b6b6b6b6", + "c3b5cecbedc3b4e3cbcbd5b2e3e3bdbbcab6b6caa4e0f8a4d5d2c3d5dfb2eddf", + "e1cbf2b8b8b4c7d4eff8a7f2b3bcc0c4b5d7efdac7cac0cda7aea1e2b3cdc1d6", + "b6b9c7d4dba5fcf2b1b3beb9f8c7c3fab4d4b1d3ecf2a9c3c8c1cab2f1a6a6bb", + "d9aef8fbcebebed5b4c3c3dfb6d2b6cec3b2f8b4c7b7b9d6c9f2faaed6b1c0d3", + "c8d2b9d7a1f2cae3d3c1bed2c3b8fcb2d3d3cab9dadab3fab9b5bfd6fad0c9ae", + "a6dad9aec7d0d0c8fce9d5a5d6d3d6c6d0daaee4d4bcb7c6add2d1dfc4b0cab9", + "d2b6b8cebbfed1e1d6cad2b6d3aebbfeb5d6cacab1d3afaecebdb5cae1d9b1af", + "d3e4aed0b6c9cab6f8c6c7f8b3b6ced2cbf8bde6d6d1cac7aef8a4bfd7d6b5b9", + "b9bebec2fafcfcc3b4ceb4ceceaaceaad6c9d6c9aecfaecfc8c6c8c6abc6abc6", + "f8f8dff8c7c9d2b7fcc6b2a5c8d6b9c4cbaecab1d6c9c9c6aec6cfe4b1d5b1b4", + ) +} diff --git a/app/src/main/java/com/local/ktv/TvFocusStyler.kt b/app/src/main/java/com/local/ktv/TvFocusStyler.kt new file mode 100644 index 0000000..b9ad282 --- /dev/null +++ b/app/src/main/java/com/local/ktv/TvFocusStyler.kt @@ -0,0 +1,100 @@ +package com.local.ktv + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.os.Build +import android.view.View +import android.view.ViewGroup +import java.util.Collections +import java.util.WeakHashMap + +/** Applies the original OTT red-to-orange focus fill to remote-control targets. */ +object TvFocusStyler { + private val installed = Collections.newSetFromMap(WeakHashMap()) + private val preservedBackgrounds = Collections.newSetFromMap(WeakHashMap()) + + /** Preserve image/video backgrounds while suppressing Android's grey focus halo. */ + fun preserveBackground(view: View) { + preservedBackgrounds.add(view) + disablePlatformHighlight(view) + if (view.isClickable && view.isEnabled && view.visibility == View.VISIBLE) { + view.isFocusable = true + view.isFocusableInTouchMode = false + } + } + + fun install(view: View) { + install(view, false) + } + + /** Force the shared focus fill on platform widgets whose stock background is stateful. */ + fun installAction(view: View) { + install(view, true) + } + + private fun install(view: View, force: Boolean) { + disablePlatformHighlight(view) + if (!view.isEnabled || view.visibility != View.VISIBLE || !view.isClickable) return + view.isFocusable = true + view.isFocusableInTouchMode = false + if (!force && (preservedBackgrounds.contains(view) || view.background?.isStateful == true)) return + if (!installed.add(view) && !force) return + val normal = view.background ?: ColorDrawable(Color.TRANSPARENT) + val normalShape = normal as? GradientDrawable + val normalCornerRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + runCatching { normalShape?.cornerRadius }.getOrNull() + } else { + null + } + val normalCornerRadii = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // Android 9's GradientDrawable getter throws when the shape uses a + // uniform radius and therefore has no per-corner array. + runCatching { normalShape?.cornerRadii?.clone() }.getOrNull() + } else { + null + } + fun focusFill() = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(Color.rgb(232, 49, 111), Color.rgb(255, 154, 48)), + ).apply { + when { + normalCornerRadii != null -> cornerRadii = normalCornerRadii.clone() + normalCornerRadius != null -> cornerRadius = normalCornerRadius + else -> cornerRadius = view.resources.displayMetrics.density * 7f + } + setStroke((view.resources.displayMetrics.density + 0.5f).toInt(), Color.argb(150, 255, 255, 255)) + } + + view.background = StateListDrawable().apply { + // Focus must never retain the normal drawable. Platform Buttons and a + // few legacy OTT assets use rectangular grey insets which remain visible + // around a rounded overlay and make the focus target look oversized. + addState(intArrayOf(android.R.attr.state_focused), focusFill()) + addState(intArrayOf(android.R.attr.state_pressed), focusFill()) + addState(intArrayOf(), normal) + } + } + + fun installTree(view: View) { + disablePlatformHighlight(view) + // Structural dialog containers own navigation, but are not action targets. + // Styling them paints the entire option sheet instead of the active option. + if (view !is ViewGroup) install(view) + if (view is ViewGroup) repeat(view.childCount) { installTree(view.getChildAt(it)) } + } + + fun disablePlatformHighlightTree(view: View) { + disablePlatformHighlight(view) + if (view is ViewGroup) repeat(view.childCount) { + disablePlatformHighlightTree(view.getChildAt(it)) + } + } + + private fun disablePlatformHighlight(view: View) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + view.defaultFocusHighlightEnabled = false + } + } +} diff --git a/app/src/main/java/com/local/ktv/player/KtvVideoView.kt b/app/src/main/java/com/local/ktv/player/KtvVideoView.kt new file mode 100644 index 0000000..4f19c6f --- /dev/null +++ b/app/src/main/java/com/local/ktv/player/KtvVideoView.kt @@ -0,0 +1,394 @@ +package com.local.ktv.player + +import android.content.Context +import android.graphics.Color +import android.media.AudioManager +import android.media.MediaPlayer +import android.net.Uri +import android.util.AttributeSet +import android.util.Log +import android.view.Gravity +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.widget.FrameLayout +import tv.danmaku.ijk.media.player.IMediaPlayer +import tv.danmaku.ijk.media.player.IjkMediaPlayer +import tv.danmaku.ijk.media.player.misc.ITrackInfo + +/** + * Kotlin port of the original MuseVideoView display structure: a FrameLayout + * containing a ResizeSurfaceView. Playback lives in one shared engine, so page + * and fullscreen changes only redirect the decoder output surface. + */ +class KtvVideoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + private val surfaceView = KtvSurfaceView(context) + private var engine: KtvPlaybackEngine? = null + private var surfaceCallback: SurfaceHolder.Callback? = null + + init { + setBackgroundColor(Color.BLACK) + addView( + surfaceView, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, Gravity.CENTER), + ) + } + + fun bind(engine: KtvPlaybackEngine) { + this.engine = engine + } + + fun setMediaOverlay(enabled: Boolean) { + surfaceView.setZOrderMediaOverlay(enabled) + } + + fun setVideoURI(uri: Uri) = engine?.setVideoUri(uri) ?: Unit + fun setOnPreparedListener(listener: MediaPlayer.OnPreparedListener?) = engine?.setOnPreparedListener(listener) ?: Unit + fun setOnCompletionListener(listener: MediaPlayer.OnCompletionListener?) = engine?.setOnCompletionListener(listener) ?: Unit + fun setOnErrorListener(listener: MediaPlayer.OnErrorListener?) = engine?.setOnErrorListener(listener) ?: Unit + fun start() = engine?.start() ?: Unit + fun pause() = engine?.pause() ?: Unit + fun seekTo(positionMs: Int) = engine?.seekTo(positionMs) ?: Unit + fun stopPlayback() = engine?.stop() ?: Unit + fun setPlaybackVolume(left: Float, right: Float) = engine?.setVolume(left, right) ?: Unit + fun setTone(step: Int) = engine?.setTone(step) ?: Unit + fun selectAudioTrack(original: Boolean): Boolean = engine?.selectAudioTrack(original) == true + fun audioTrackCount(): Int = engine?.audioTrackCount() ?: 0 + fun selectAudioChannel(channel: Int, volume: Float) = engine?.selectAudioChannel(channel, volume) ?: Unit + val isPlaying: Boolean get() = engine?.isPlaying == true + val currentPosition: Int get() = engine?.currentPosition ?: 0 + val duration: Int get() = engine?.duration ?: 0 + + internal fun updateVideoSize(width: Int, height: Int) { + surfaceView.setVideoSize(width, height) + } + + internal fun installSurfaceCallback(callback: SurfaceHolder.Callback) { + surfaceCallback?.let(surfaceView.holder::removeCallback) + surfaceCallback = callback + surfaceView.holder.addCallback(callback) + } + + internal fun currentHolder(): SurfaceHolder = surfaceView.holder +} + +/** Resize behavior ported from the original ResizeSurfaceView, scale mode 1. */ +private class KtvSurfaceView(context: Context) : SurfaceView(context) { + private var videoWidth = 0 + private var videoHeight = 0 + + fun setVideoSize(width: Int, height: Int) { + videoWidth = width + videoHeight = height + if (width > 0 && height > 0) holder.setFixedSize(width, height) + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val maxWidth = getDefaultSize(videoWidth, widthMeasureSpec).coerceAtLeast(1) + val maxHeight = getDefaultSize(videoHeight, heightMeasureSpec).coerceAtLeast(1) + var measuredWidth = maxWidth + var measuredHeight = measuredWidth * 9 / 16 + if (measuredHeight > maxHeight) { + measuredHeight = maxHeight + measuredWidth = measuredHeight * 16 / 9 + } + setMeasuredDimension(measuredWidth.coerceAtLeast(1), measuredHeight.coerceAtLeast(1)) + } +} + +class KtvPlaybackEngine(context: Context) { + private companion object { + const val TAG = "KtvPlaybackEngine" + const val FFP_PROP_FLOAT_PLAYBACK_PITCH = 10008 + } + + private var ijkPlayer: IjkMediaPlayer? = null + private var targetView: KtvVideoView? = null + private var prepared = false + private var startWhenPrepared = false + private var preparedListener: MediaPlayer.OnPreparedListener? = null + private var completionListener: MediaPlayer.OnCompletionListener? = null + private var errorListener: MediaPlayer.OnErrorListener? = null + private var videoWidth = 0 + private var videoHeight = 0 + private var toneStep = 0 + + val isPlaying: Boolean get() = runCatching { ijkPlayer?.isPlaying == true }.getOrDefault(false) + val currentPosition: Int get() = runCatching { + (ijkPlayer?.currentPosition ?: 0L) + .coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + }.getOrDefault(0) + val duration: Int get() = runCatching { + (ijkPlayer?.duration ?: 0L) + .coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + }.getOrDefault(0) + + fun setOnPreparedListener(listener: MediaPlayer.OnPreparedListener?) { + preparedListener = listener + } + + fun setOnCompletionListener(listener: MediaPlayer.OnCompletionListener?) { + completionListener = listener + } + + fun setOnErrorListener(listener: MediaPlayer.OnErrorListener?) { + errorListener = listener + } + + fun attach(view: KtvVideoView) { + targetView = view + view.bind(this) + view.keepScreenOn = true + view.updateVideoSize(videoWidth, videoHeight) + view.installSurfaceCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + if (targetView === view) { + Log.i(TAG, "surfaceCreated valid=${holder.surface?.isValid == true}") + setOutputHolder(holder) + } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit + + override fun surfaceDestroyed(holder: SurfaceHolder) { + if (targetView === view) { + Log.i(TAG, "surfaceDestroyed") + runCatching { ijkPlayer?.setDisplay(null) } + } + } + }) + view.currentHolder().takeIf { it.surface?.isValid == true }?.let(::setOutputHolder) + } + + fun setVideoUri(uri: Uri) { + prepared = false + startWhenPrepared = false + Log.i(TAG, "setVideoUri uri=$uri surface=${targetView?.currentHolder()?.surface?.isValid == true}") + val ijk = ensureIjkPlayer() + if (ijk != null) { + val firstAttempt = runCatching { prepareIjkPlayer(ijk, uri) } + if (firstAttempt.isFailure) { + Log.e(TAG, "IJK prepare failed; recreating player", firstAttempt.exceptionOrNull()) + releaseIjk() + val retry = ensureIjkPlayer() + val retryAttempt = retry?.let { runCatching { prepareIjkPlayer(it, uri) } } + if (retryAttempt == null || retryAttempt.isFailure) { + Log.e( + TAG, + "IJK recreate failed; platform fallback is disabled", + retryAttempt?.exceptionOrNull(), + ) + prepared = false + errorListener?.onError(null, MediaPlayer.MEDIA_ERROR_UNKNOWN, 0) + } + } + } else { + prepared = false + Log.e(TAG, "IJK unavailable; platform fallback is disabled") + errorListener?.onError(null, MediaPlayer.MEDIA_ERROR_UNKNOWN, 0) + } + } + + private fun prepareIjkPlayer(player: IjkMediaPlayer, uri: Uri) { + player.reset() + configure(player) + targetView?.currentHolder()?.takeIf { it.surface?.isValid == true }?.let(player::setDisplay) + val source = if (uri.scheme.equals("file", true)) uri.path.orEmpty() else uri.toString() + Log.i(TAG, "prepare source=$source surface=${targetView?.currentHolder()?.surface?.isValid == true}") + player.setDataSource(source) + player.prepareAsync() + } + + fun start() { + if (!prepared) { + startWhenPrepared = true + Log.i(TAG, "start deferred until prepared") + return + } + Log.i(TAG, "start prepared player") + runCatching { ijkPlayer?.start() } + } + + fun pause() { + startWhenPrepared = false + runCatching { + if (ijkPlayer?.isPlaying == true) ijkPlayer?.pause() + } + } + + fun seekTo(positionMs: Int) { + val target = positionMs.coerceAtLeast(0) + runCatching { + ijkPlayer?.seekTo(target.toLong()) + } + } + + fun stop() { + startWhenPrepared = false + prepared = false + runCatching { ijkPlayer?.stop() } + } + + fun setVolume(left: Float, right: Float) { + val safeLeft = left.coerceIn(0f, 1f) + val safeRight = right.coerceIn(0f, 1f) + runCatching { ijkPlayer?.setVolume(safeLeft, safeRight) } + } + + /** Matches the original 32-bit backend: -5..5 steps, 8% per step. */ + fun setTone(step: Int) { + toneStep = step.coerceIn(-5, 5) + ijkPlayer?.let(::applyTone) + } + + private fun applyTone(player: IjkMediaPlayer) { + val pitch = 1.0f + toneStep * 0.08f + runCatching { + val method = IjkMediaPlayer::class.java.getDeclaredMethod( + "_setPropertyFloat", + Int::class.javaPrimitiveType, + Float::class.javaPrimitiveType, + ) + method.isAccessible = true + method.invoke(player, FFP_PROP_FLOAT_PLAYBACK_PITCH, pitch) + Log.i(TAG, "tone step=$toneStep pitch=$pitch") + }.onFailure { Log.e(TAG, "Cannot apply tone step=$toneStep", it) } + } + + fun selectAudioTrack(original: Boolean): Boolean = runCatching { + val ijk = ijkPlayer + if (ijk != null) { + val audioTracks = ijk.trackInfo.indices.filter { + ijk.trackInfo[it].trackType == ITrackInfo.MEDIA_TRACK_TYPE_AUDIO + } + if (audioTracks.size < 2) { + Log.i(TAG, "audio track switch unavailable tracks=$audioTracks original=$original") + return@runCatching false + } + // 原版在没有明确轨道索引时按“第一条音轨=原唱、第二条音轨=伴唱”回退。 + // trackInfo 还可能含有视频轨,所以使用筛选后的音轨顺序。 + val targetTrack = if (original) audioTracks[0] else audioTracks[1] + val selectedTrack = ijk.getSelectedTrack(ITrackInfo.MEDIA_TRACK_TYPE_AUDIO) + if (selectedTrack != targetTrack) { + ijk.selectTrack(targetTrack) + } + Log.i( + TAG, + "audio track switch tracks=$audioTracks selected=$selectedTrack target=$targetTrack original=$original", + ) + return@runCatching true + } + false + }.getOrDefault(false) + + fun audioTrackCount(): Int = runCatching { + ijkPlayer?.trackInfo?.count { it.trackType == ITrackInfo.MEDIA_TRACK_TYPE_AUDIO } ?: 0 + }.getOrDefault(0) + + fun selectAudioChannel(channel: Int, volume: Float) { + val safeVolume = volume.coerceIn(0f, 1f) + val ijk = ijkPlayer + if (ijk != null) { + runCatching { + ijk.setVolume(safeVolume, safeVolume) + ijk.seletcAudioChannel(channel) + Log.i(TAG, "audio channel=$channel volume=$safeVolume") + }.onFailure { Log.e(TAG, "Cannot select audio channel=$channel", it) } + return + } + when (channel) { + IjkMediaPlayer.AUDIO_CHANNEL_LEFT -> setVolume(safeVolume, 0f) + IjkMediaPlayer.AUDIO_CHANNEL_RIGHT -> setVolume(0f, safeVolume) + else -> setVolume(safeVolume, safeVolume) + } + } + + fun release() { + startWhenPrepared = false + prepared = false + releaseIjk() + targetView = null + } + + private fun ensureIjkPlayer(): IjkMediaPlayer? { + ijkPlayer?.let { return it } + return runCatching { + IjkMediaPlayer().also { + ijkPlayer = it + com.local.ktv.AppPaths.removeNativeLegacyLogDirectory() + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed( + { com.local.ktv.AppPaths.removeNativeLegacyLogDirectory() }, + 1_500L, + ) + } + } + .onFailure { Log.e(TAG, "Cannot create IJK player", it) } + .getOrNull() + } + + private fun configure(player: IjkMediaPlayer) { + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_loop_filter", 48L) + // The switch path seeks the newly opened audio stream back to the + // rendered position, so audio can remain the A/V master without making + // the MV chase a future packet after a track change. + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "sync-type", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "opensles", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "soundtouch", 1L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "overlay-format", IjkMediaPlayer.SDL_FCC_RV16.toLong()) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 1L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max-buffer-size", 6_291_456L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "http-detect-range-support", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzemaxduration", 100L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 1_048_576L) + // Old KTV MPEG-TS files corrupt reference frames in emulator/device OMX decoders. + // Keep the whole playback path on IJK's bundled FFmpeg decoder. + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-all-videos", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-auto-rotate", 0L) + player.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-handle-resolution-change", 0L) + player.setAudioStreamType(AudioManager.STREAM_MUSIC) + player.setScreenOnWhilePlaying(true) + applyTone(player) + player.setOnPreparedListener { preparedPlayer: IMediaPlayer -> + prepared = true + videoWidth = preparedPlayer.videoWidth + videoHeight = preparedPlayer.videoHeight + targetView?.updateVideoSize(videoWidth, videoHeight) + Log.i(TAG, "onPrepared size=${videoWidth}x$videoHeight deferred=$startWhenPrepared") + preparedListener?.onPrepared(null) + if (startWhenPrepared && !preparedPlayer.isPlaying) { + Log.i(TAG, "starting deferred player") + runCatching { preparedPlayer.start() } + } + } + player.setOnVideoSizeChangedListener { _, width, height, _, _ -> + videoWidth = width + videoHeight = height + targetView?.updateVideoSize(width, height) + } + player.setOnCompletionListener { completionListener?.onCompletion(null) } + player.setOnErrorListener { _, what, extra -> + prepared = false + Log.e(TAG, "onError what=$what extra=$extra") + errorListener?.onError(null, what, extra) ?: true + } + } + + private fun setOutputHolder(holder: SurfaceHolder) { + Log.i(TAG, "setOutputHolder valid=${holder.surface?.isValid == true}") + runCatching { ijkPlayer?.setDisplay(holder) } + } + + private fun releaseIjk() { + runCatching { ijkPlayer?.setDisplay(null) } + runCatching { ijkPlayer?.release() } + ijkPlayer = null + } +} diff --git a/app/src/main/java/com/local/ktv/player/PlayerControllerView.kt b/app/src/main/java/com/local/ktv/player/PlayerControllerView.kt new file mode 100644 index 0000000..6112296 --- /dev/null +++ b/app/src/main/java/com/local/ktv/player/PlayerControllerView.kt @@ -0,0 +1,170 @@ +package com.local.ktv.player + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.local.ktv.Song +import com.local.ktv.TvFocusStyler + +class PlayerControllerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + interface OnControlListener { + fun onPlayPause() + fun onPrev() + fun onNext() + fun onVocalSwitch(original: Boolean) + fun onVolume() + fun onFullScreen() + } + + private val main = Handler(Looper.getMainLooper()) + private val autoHideTask = Runnable(::hide) + private lateinit var songTitle: TextView + private lateinit var songSinger: TextView + private lateinit var playPause: TextView + private lateinit var vocalSwitch: TextView + private var listener: OnControlListener? = null + private var originalVocal = false + private var controlVisible = true + + init { + setBackgroundColor(Color.argb(120, 0, 0, 0)) + setOnClickListener { resetAutoHide() } + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(buildTopBar(), LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(buildMiddle(), LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + addView(buildBottomBar(), LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + }, LayoutParams(MATCH_PARENT, MATCH_PARENT)) + show() + } + + fun setOnControlListener(listener: OnControlListener?) { + this.listener = listener + } + + fun updateSongInfo(song: Song?) { + songTitle.text = song?.title ?: "暂无歌曲" + songSinger.text = song?.singer.orEmpty() + } + + fun updatePlayState(playing: Boolean) { + playPause.text = if (playing) "暂停" else "播放" + } + + fun setVocalMode(original: Boolean) { + originalVocal = original + vocalSwitch.text = if (original) "伴唱" else "原唱" + } + + fun show() { + controlVisible = true + visibility = VISIBLE + resetAutoHide() + } + + fun hide() { + controlVisible = false + visibility = GONE + main.removeCallbacks(autoHideTask) + } + + fun toggle() = if (controlVisible) hide() else show() + + fun isControlVisible(): Boolean = controlVisible + + fun resetAutoHide() { + main.removeCallbacks(autoHideTask) + main.postDelayed(autoHideTask, 5_000) + } + + override fun onDetachedFromWindow() { + main.removeCallbacks(autoHideTask) + super.onDetachedFromWindow() + } + + private fun buildTopBar() = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(12), dp(16), dp(8)) + songTitle = label("暂无歌曲", GOLD, 20).also { + it.gravity = Gravity.CENTER_VERTICAL + addView(it, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + } + songSinger = label("", TEXT_DIM, 14).also { + it.gravity = Gravity.CENTER_VERTICAL + addView(it, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + } + } + + private fun buildMiddle() = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + playPause = button("播放", TEXT_WHITE, 22) { listener?.onPlayPause() } + addView(playPause, LinearLayout.LayoutParams(dp(96), dp(96))) + } + + private fun buildBottomBar() = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(8), dp(12), dp(12)) + addControl("上一首") { listener?.onPrev() } + vocalSwitch = button("原唱", ACCENT_RED, 16) { + // MainActivity 确认目标模式后会通过 setVocalMode 回写,避免按钮先翻转造成提示相反。 + listener?.onVocalSwitch(!originalVocal) + }.also { addView(it, LinearLayout.LayoutParams(0, dp(48), 1f)) } + addControl("音量") { listener?.onVolume() } + addControl("全屏") { listener?.onFullScreen() } + addControl("下一首") { listener?.onNext() } + } + + private fun LinearLayout.addControl(text: String, action: () -> Unit) { + addView(button(text, TEXT_WHITE, 16, action), LinearLayout.LayoutParams(0, dp(48), 1f)) + } + + private fun label(text: String, color: Int, size: Int) = TextView(context).apply { + this.text = text + setTextColor(color) + textSize = size.toFloat() + isSingleLine = true + } + + private fun button(text: String, color: Int, size: Int, action: () -> Unit) = + label(text, color, size).apply { + gravity = Gravity.CENTER + isFocusable = true + isClickable = true + background = GradientDrawable().apply { + setColor(Color.argb(150, 22, 31, 42)) + cornerRadius = dp(6).toFloat() + setStroke(dp(1), Color.argb(80, 241, 241, 241)) + } + setOnClickListener { + action() + resetAutoHide() + } + TvFocusStyler.install(this) + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private companion object { + const val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT + const val WRAP_CONTENT = ViewGroup.LayoutParams.WRAP_CONTENT + val GOLD = Color.rgb(245, 190, 89) + val ACCENT_RED = Color.rgb(253, 51, 89) + val TEXT_WHITE = Color.rgb(241, 241, 241) + val TEXT_DIM = Color.argb(180, 241, 241, 241) + } +} diff --git a/app/src/main/java/com/local/ktv/player/VocalSwitchHelper.kt b/app/src/main/java/com/local/ktv/player/VocalSwitchHelper.kt new file mode 100644 index 0000000..6e77670 --- /dev/null +++ b/app/src/main/java/com/local/ktv/player/VocalSwitchHelper.kt @@ -0,0 +1,23 @@ +package com.local.ktv.player + +import android.media.MediaPlayer +import com.local.ktv.Song + +object VocalSwitchHelper { + @JvmStatic + fun switchVocal(player: MediaPlayer?, song: Song?, original: Boolean): Boolean { + if (player == null || song == null || song.accomp <= 0) return false + return runCatching { + val volumes = channelVolumes(song, original) + player.setVolume(volumes[0], volumes[1]) + true + }.getOrDefault(false) + } + + @JvmStatic + fun channelVolumes(song: Song?, original: Boolean): FloatArray = when (song?.accomp) { + 1 -> if (original) floatArrayOf(0f, 1f) else floatArrayOf(1f, 0f) + 2 -> if (original) floatArrayOf(1f, 0f) else floatArrayOf(0f, 1f) + else -> floatArrayOf(1f, 1f) + } +} diff --git a/app/src/main/jniLibs/arm64-v8a/libijkffmpeg.so b/app/src/main/jniLibs/arm64-v8a/libijkffmpeg.so new file mode 100644 index 0000000..387c59b Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libijkffmpeg.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libijkplayer.so b/app/src/main/jniLibs/arm64-v8a/libijkplayer.so new file mode 100644 index 0000000..f2c4e25 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libijkplayer.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libijksdl.so b/app/src/main/jniLibs/arm64-v8a/libijksdl.so new file mode 100644 index 0000000..c9ba2c5 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libijksdl.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libc++_shared.so b/app/src/main/jniLibs/armeabi-v7a/libc++_shared.so new file mode 100644 index 0000000..63099fb Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libc++_shared.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libijkffmpeg.so b/app/src/main/jniLibs/armeabi-v7a/libijkffmpeg.so new file mode 100644 index 0000000..158dfaf Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libijkffmpeg.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libijkplayer.so b/app/src/main/jniLibs/armeabi-v7a/libijkplayer.so new file mode 100644 index 0000000..ee71b66 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libijkplayer.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libijksdl.so b/app/src/main/jniLibs/armeabi-v7a/libijksdl.so new file mode 100644 index 0000000..0f6f92d Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libijksdl.so differ diff --git a/app/src/main/res/anim/abc_fade_in.xml b/app/src/main/res/anim/abc_fade_in.xml new file mode 100644 index 0000000..2fcd5e5 --- /dev/null +++ b/app/src/main/res/anim/abc_fade_in.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_fade_out.xml b/app/src/main/res/anim/abc_fade_out.xml new file mode 100644 index 0000000..5e037be --- /dev/null +++ b/app/src/main/res/anim/abc_fade_out.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_grow_fade_in_from_bottom.xml b/app/src/main/res/anim/abc_grow_fade_in_from_bottom.xml new file mode 100644 index 0000000..c406d8a --- /dev/null +++ b/app/src/main/res/anim/abc_grow_fade_in_from_bottom.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_popup_enter.xml b/app/src/main/res/anim/abc_popup_enter.xml new file mode 100644 index 0000000..6f69f10 --- /dev/null +++ b/app/src/main/res/anim/abc_popup_enter.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_popup_exit.xml b/app/src/main/res/anim/abc_popup_exit.xml new file mode 100644 index 0000000..9cef3ba --- /dev/null +++ b/app/src/main/res/anim/abc_popup_exit.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_shrink_fade_out_from_bottom.xml b/app/src/main/res/anim/abc_shrink_fade_out_from_bottom.xml new file mode 100644 index 0000000..7c33048 --- /dev/null +++ b/app/src/main/res/anim/abc_shrink_fade_out_from_bottom.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_slide_in_bottom.xml b/app/src/main/res/anim/abc_slide_in_bottom.xml new file mode 100644 index 0000000..852d2c9 --- /dev/null +++ b/app/src/main/res/anim/abc_slide_in_bottom.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_slide_in_top.xml b/app/src/main/res/anim/abc_slide_in_top.xml new file mode 100644 index 0000000..968907f --- /dev/null +++ b/app/src/main/res/anim/abc_slide_in_top.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_slide_out_bottom.xml b/app/src/main/res/anim/abc_slide_out_bottom.xml new file mode 100644 index 0000000..40576bd --- /dev/null +++ b/app/src/main/res/anim/abc_slide_out_bottom.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_slide_out_top.xml b/app/src/main/res/anim/abc_slide_out_top.xml new file mode 100644 index 0000000..5c29fc0 --- /dev/null +++ b/app/src/main/res/anim/abc_slide_out_top.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_tooltip_enter.xml b/app/src/main/res/anim/abc_tooltip_enter.xml new file mode 100644 index 0000000..97dfcc2 --- /dev/null +++ b/app/src/main/res/anim/abc_tooltip_enter.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/abc_tooltip_exit.xml b/app/src/main/res/anim/abc_tooltip_exit.xml new file mode 100644 index 0000000..1cdba77 --- /dev/null +++ b/app/src/main/res/anim/abc_tooltip_exit.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/dkplayer_anim_alpha_in.xml b/app/src/main/res/anim/dkplayer_anim_alpha_in.xml new file mode 100644 index 0000000..f7969f8 --- /dev/null +++ b/app/src/main/res/anim/dkplayer_anim_alpha_in.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/dkplayer_anim_alpha_out.xml b/app/src/main/res/anim/dkplayer_anim_alpha_out.xml new file mode 100644 index 0000000..29fbce5 --- /dev/null +++ b/app/src/main/res/anim/dkplayer_anim_alpha_out.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/dkplayer_anim_center_view.xml b/app/src/main/res/anim/dkplayer_anim_center_view.xml new file mode 100644 index 0000000..29fbce5 --- /dev/null +++ b/app/src/main/res/anim/dkplayer_anim_center_view.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/empty_anim.xml b/app/src/main/res/anim/empty_anim.xml new file mode 100644 index 0000000..cc398ef --- /dev/null +++ b/app/src/main/res/anim/empty_anim.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 0000000..19011ed --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..0fe5f9b --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fragment_enter.xml b/app/src/main/res/anim/fragment_enter.xml new file mode 100644 index 0000000..dafa6f2 --- /dev/null +++ b/app/src/main/res/anim/fragment_enter.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fragment_pop_enter.xml b/app/src/main/res/anim/fragment_pop_enter.xml new file mode 100644 index 0000000..d3a9a2f --- /dev/null +++ b/app/src/main/res/anim/fragment_pop_enter.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/h_fragment_enter.xml b/app/src/main/res/anim/h_fragment_enter.xml new file mode 100644 index 0000000..fed8c2f --- /dev/null +++ b/app/src/main/res/anim/h_fragment_enter.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/h_fragment_exit.xml b/app/src/main/res/anim/h_fragment_exit.xml new file mode 100644 index 0000000..27739cb --- /dev/null +++ b/app/src/main/res/anim/h_fragment_exit.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/h_fragment_pop_enter.xml b/app/src/main/res/anim/h_fragment_pop_enter.xml new file mode 100644 index 0000000..454c687 --- /dev/null +++ b/app/src/main/res/anim/h_fragment_pop_enter.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/h_fragment_pop_exit.xml b/app/src/main/res/anim/h_fragment_pop_exit.xml new file mode 100644 index 0000000..285b0aa --- /dev/null +++ b/app/src/main/res/anim/h_fragment_pop_exit.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/loading_anim.xml b/app/src/main/res/anim/loading_anim.xml new file mode 100644 index 0000000..be569c6 --- /dev/null +++ b/app/src/main/res/anim/loading_anim.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/no_anim.xml b/app/src/main/res/anim/no_anim.xml new file mode 100644 index 0000000..02038fe --- /dev/null +++ b/app/src/main/res/anim/no_anim.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/pop_exit_no_anim.xml b/app/src/main/res/anim/pop_exit_no_anim.xml new file mode 100644 index 0000000..1de6cd3 --- /dev/null +++ b/app/src/main/res/anim/pop_exit_no_anim.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml new file mode 100644 index 0000000..7e76a69 --- /dev/null +++ b/app/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..e50c0a1 --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 0000000..92463ea --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 0000000..805956a --- /dev/null +++ b/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/v_fragment_enter.xml b/app/src/main/res/anim/v_fragment_enter.xml new file mode 100644 index 0000000..403210d --- /dev/null +++ b/app/src/main/res/anim/v_fragment_enter.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/v_fragment_exit.xml b/app/src/main/res/anim/v_fragment_exit.xml new file mode 100644 index 0000000..e322ecc --- /dev/null +++ b/app/src/main/res/anim/v_fragment_exit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/v_fragment_pop_enter.xml b/app/src/main/res/anim/v_fragment_pop_enter.xml new file mode 100644 index 0000000..a84b917 --- /dev/null +++ b/app/src/main/res/anim/v_fragment_pop_enter.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/v_fragment_pop_exit.xml b/app/src/main/res/anim/v_fragment_pop_exit.xml new file mode 100644 index 0000000..26d970a --- /dev/null +++ b/app/src/main/res/anim/v_fragment_pop_exit.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/home_banner_0.webp b/app/src/main/res/drawable-nodpi/home_banner_0.webp new file mode 100644 index 0000000..308730f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_banner_0.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_banner_1.webp b/app/src/main/res/drawable-nodpi/home_banner_1.webp new file mode 100644 index 0000000..d3a47bc Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_banner_1.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_banner_2.webp b/app/src/main/res/drawable-nodpi/home_banner_2.webp new file mode 100644 index 0000000..110d282 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_banner_2.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_banner_3.webp b/app/src/main/res/drawable-nodpi/home_banner_3.webp new file mode 100644 index 0000000..f75ca15 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_banner_3.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_banner_4.webp b/app/src/main/res/drawable-nodpi/home_banner_4.webp new file mode 100644 index 0000000..6c9007c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_banner_4.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_card_category.webp b/app/src/main/res/drawable-nodpi/home_card_category.webp new file mode 100644 index 0000000..c0ecf7e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_card_category.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_card_favorite.webp b/app/src/main/res/drawable-nodpi/home_card_favorite.webp new file mode 100644 index 0000000..aaf0674 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_card_favorite.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_card_local.webp b/app/src/main/res/drawable-nodpi/home_card_local.webp new file mode 100644 index 0000000..7f6cd81 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_card_local.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_card_rank.webp b/app/src/main/res/drawable-nodpi/home_card_rank.webp new file mode 100644 index 0000000..0028192 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_card_rank.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_card_regular.webp b/app/src/main/res/drawable-nodpi/home_card_regular.webp new file mode 100644 index 0000000..d5541aa Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_card_regular.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_card_singer.webp b/app/src/main/res/drawable-nodpi/home_card_singer.webp new file mode 100644 index 0000000..7beb3d4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_card_singer.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_card_song.webp b/app/src/main/res/drawable-nodpi/home_card_song.webp new file mode 100644 index 0000000..865245c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_card_song.webp differ diff --git a/app/src/main/res/drawable-nodpi/home_new_song_poster.jpg b/app/src/main/res/drawable-nodpi/home_new_song_poster.jpg new file mode 100644 index 0000000..f6783c8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/home_new_song_poster.jpg differ diff --git a/app/src/main/res/drawable-nodpi/ic_launcher_art.png b/app/src/main/res/drawable-nodpi/ic_launcher_art.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_launcher_art.png differ diff --git a/app/src/main/res/drawable-nodpi/ktv_logo.png b/app/src/main/res/drawable-nodpi/ktv_logo.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ktv_logo.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ab_share_pack_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_ab_share_pack_mtrl_alpha.9.png new file mode 100644 index 0000000..86404d5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ab_share_pack_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_btn_check_to_on_mtrl_000.png b/app/src/main/res/drawable-xxhdpi/abc_btn_check_to_on_mtrl_000.png new file mode 100644 index 0000000..e33b5ce Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_btn_check_to_on_mtrl_000.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_btn_check_to_on_mtrl_015.png b/app/src/main/res/drawable-xxhdpi/abc_btn_check_to_on_mtrl_015.png new file mode 100644 index 0000000..1703ab5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_btn_check_to_on_mtrl_015.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_btn_radio_to_on_mtrl_000.png b/app/src/main/res/drawable-xxhdpi/abc_btn_radio_to_on_mtrl_000.png new file mode 100644 index 0000000..09ce056 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_btn_radio_to_on_mtrl_000.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_btn_radio_to_on_mtrl_015.png b/app/src/main/res/drawable-xxhdpi/abc_btn_radio_to_on_mtrl_015.png new file mode 100644 index 0000000..dc9f5d0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_btn_radio_to_on_mtrl_015.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_btn_switch_to_on_mtrl_00001.9.png b/app/src/main/res/drawable-xxhdpi/abc_btn_switch_to_on_mtrl_00001.9.png new file mode 100644 index 0000000..bab952a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_btn_switch_to_on_mtrl_00001.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_btn_switch_to_on_mtrl_00012.9.png b/app/src/main/res/drawable-xxhdpi/abc_btn_switch_to_on_mtrl_00012.9.png new file mode 100644 index 0000000..ea0915f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_btn_switch_to_on_mtrl_00012.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_cab_background_top_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_cab_background_top_mtrl_alpha.9.png new file mode 100644 index 0000000..91e2cba Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_cab_background_top_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_commit_search_api_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/abc_ic_commit_search_api_mtrl_alpha.png new file mode 100644 index 0000000..c2c0f07 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_commit_search_api_mtrl_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_menu_copy_mtrl_am_alpha.png b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_copy_mtrl_am_alpha.png new file mode 100644 index 0000000..6d6b02f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_copy_mtrl_am_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_menu_cut_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_cut_mtrl_alpha.png new file mode 100644 index 0000000..5524ce3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_cut_mtrl_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_menu_paste_mtrl_am_alpha.png b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_paste_mtrl_am_alpha.png new file mode 100644 index 0000000..d1ea8fd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_paste_mtrl_am_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_menu_selectall_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_selectall_mtrl_alpha.png new file mode 100644 index 0000000..1cf9ff6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_selectall_mtrl_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_menu_share_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_share_mtrl_alpha.png new file mode 100644 index 0000000..57e8201 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_menu_share_mtrl_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_16dp.png b/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_16dp.png new file mode 100644 index 0000000..21a5939 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_16dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_36dp.png b/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_36dp.png new file mode 100644 index 0000000..d8c76da Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_48dp.png b/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_48dp.png new file mode 100644 index 0000000..0cc7bd4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_star_black_48dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_16dp.png b/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_16dp.png new file mode 100644 index 0000000..47a7f09 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_16dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_36dp.png b/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_36dp.png new file mode 100644 index 0000000..de97a3b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_36dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_48dp.png b/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_48dp.png new file mode 100644 index 0000000..9d3afc8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_ic_star_half_black_48dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_list_divider_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_list_divider_mtrl_alpha.9.png new file mode 100644 index 0000000..b8b83bc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_list_divider_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_list_focused_holo.9.png b/app/src/main/res/drawable-xxhdpi/abc_list_focused_holo.9.png new file mode 100644 index 0000000..1ea6c2b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_list_focused_holo.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_list_longpressed_holo.9.png b/app/src/main/res/drawable-xxhdpi/abc_list_longpressed_holo.9.png new file mode 100644 index 0000000..6a25bb0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_list_longpressed_holo.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_list_pressed_holo_dark.9.png b/app/src/main/res/drawable-xxhdpi/abc_list_pressed_holo_dark.9.png new file mode 100644 index 0000000..431a00a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_list_pressed_holo_dark.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_list_pressed_holo_light.9.png b/app/src/main/res/drawable-xxhdpi/abc_list_pressed_holo_light.9.png new file mode 100644 index 0000000..cc59343 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_list_pressed_holo_light.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_list_selector_disabled_holo_dark.9.png b/app/src/main/res/drawable-xxhdpi/abc_list_selector_disabled_holo_dark.9.png new file mode 100644 index 0000000..07ec1d8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_list_selector_disabled_holo_dark.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_list_selector_disabled_holo_light.9.png b/app/src/main/res/drawable-xxhdpi/abc_list_selector_disabled_holo_light.9.png new file mode 100644 index 0000000..8e21a85 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_list_selector_disabled_holo_light.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_menu_hardkey_panel_mtrl_mult.9.png b/app/src/main/res/drawable-xxhdpi/abc_menu_hardkey_panel_mtrl_mult.9.png new file mode 100644 index 0000000..a6b99b1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_menu_hardkey_panel_mtrl_mult.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_popup_background_mtrl_mult.9.png b/app/src/main/res/drawable-xxhdpi/abc_popup_background_mtrl_mult.9.png new file mode 100644 index 0000000..a6e51e8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_popup_background_mtrl_mult.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_off_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_off_mtrl_alpha.png new file mode 100644 index 0000000..f6aba5f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_off_mtrl_alpha.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_to_pressed_mtrl_000.png b/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_to_pressed_mtrl_000.png new file mode 100644 index 0000000..40a3f98 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_to_pressed_mtrl_000.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_to_pressed_mtrl_005.png b/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_to_pressed_mtrl_005.png new file mode 100644 index 0000000..5ceb05b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_scrubber_control_to_pressed_mtrl_005.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_scrubber_primary_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_scrubber_primary_mtrl_alpha.9.png new file mode 100644 index 0000000..46d2b3d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_scrubber_primary_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_scrubber_track_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_scrubber_track_mtrl_alpha.9.png new file mode 100644 index 0000000..62bb368 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_scrubber_track_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_spinner_mtrl_am_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_spinner_mtrl_am_alpha.9.png new file mode 100644 index 0000000..1a836b2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_spinner_mtrl_am_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_switch_track_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_switch_track_mtrl_alpha.9.png new file mode 100644 index 0000000..133133f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_switch_track_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_tab_indicator_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_tab_indicator_mtrl_alpha.9.png new file mode 100644 index 0000000..3d97aa7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_tab_indicator_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_left_mtrl_dark.png b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_left_mtrl_dark.png new file mode 100644 index 0000000..c8843cd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_left_mtrl_dark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_left_mtrl_light.png b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_left_mtrl_light.png new file mode 100644 index 0000000..4b7ea62 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_left_mtrl_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_middle_mtrl_dark.png b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_middle_mtrl_dark.png new file mode 100644 index 0000000..0f37df7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_middle_mtrl_dark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_middle_mtrl_light.png b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_middle_mtrl_light.png new file mode 100644 index 0000000..235b5ea Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_middle_mtrl_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_right_mtrl_dark.png b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_right_mtrl_dark.png new file mode 100644 index 0000000..5c5c235 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_right_mtrl_dark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_right_mtrl_light.png b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_right_mtrl_light.png new file mode 100644 index 0000000..500471b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_text_select_handle_right_mtrl_light.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_textfield_activated_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_textfield_activated_mtrl_alpha.9.png new file mode 100644 index 0000000..c9e11b7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_textfield_activated_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_textfield_default_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_textfield_default_mtrl_alpha.9.png new file mode 100644 index 0000000..9bf968e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_textfield_default_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_textfield_search_activated_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_textfield_search_activated_mtrl_alpha.9.png new file mode 100644 index 0000000..c6853bb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_textfield_search_activated_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/abc_textfield_search_default_mtrl_alpha.9.png b/app/src/main/res/drawable-xxhdpi/abc_textfield_search_default_mtrl_alpha.9.png new file mode 100644 index 0000000..f28e0e2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/abc_textfield_search_default_mtrl_alpha.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_app.webp b/app/src/main/res/drawable-xxhdpi/bg_app.webp new file mode 100644 index 0000000..9be26b6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_app.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_bind_qrcode.png b/app/src/main/res/drawable-xxhdpi/bg_bind_qrcode.png new file mode 100644 index 0000000..10aca75 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_bind_qrcode.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_button_mobile.png b/app/src/main/res/drawable-xxhdpi/bg_button_mobile.png new file mode 100644 index 0000000..d11a6bb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_button_mobile.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_cavca_sign.png b/app/src/main/res/drawable-xxhdpi/bg_cavca_sign.png new file mode 100644 index 0000000..20615ad Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_cavca_sign.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_corner_qrcode.9.png b/app/src/main/res/drawable-xxhdpi/bg_corner_qrcode.9.png new file mode 100644 index 0000000..6612b87 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_corner_qrcode.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_dialog_qrcode.webp b/app/src/main/res/drawable-xxhdpi/bg_dialog_qrcode.webp new file mode 100644 index 0000000..94c7ced Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_dialog_qrcode.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_dialog_upgrade.webp b/app/src/main/res/drawable-xxhdpi/bg_dialog_upgrade.webp new file mode 100644 index 0000000..fd79366 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_dialog_upgrade.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_dialog_user_mv.webp b/app/src/main/res/drawable-xxhdpi/bg_dialog_user_mv.webp new file mode 100644 index 0000000..72733e4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_dialog_user_mv.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_disco.webp b/app/src/main/res/drawable-xxhdpi/bg_disco.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_disco.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_fire_alarm.webp b/app/src/main/res/drawable-xxhdpi/bg_fire_alarm.webp new file mode 100644 index 0000000..b18440d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_fire_alarm.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_launch.webp b/app/src/main/res/drawable-xxhdpi/bg_launch.webp new file mode 100644 index 0000000..9be26b6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_launch.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_mi_dialog.9.png b/app/src/main/res/drawable-xxhdpi/bg_mi_dialog.9.png new file mode 100644 index 0000000..44a229e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_mi_dialog.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_mi_guide.webp b/app/src/main/res/drawable-xxhdpi/bg_mi_guide.webp new file mode 100644 index 0000000..2e75652 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_mi_guide.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_mi_guide_selected.webp b/app/src/main/res/drawable-xxhdpi/bg_mi_guide_selected.webp new file mode 100644 index 0000000..01b18ed Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_mi_guide_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_ott_guide_dialog.webp b/app/src/main/res/drawable-xxhdpi/bg_ott_guide_dialog.webp new file mode 100644 index 0000000..78664ab Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_ott_guide_dialog.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_pay_sing_charge_qrcode_layout.webp b/app/src/main/res/drawable-xxhdpi/bg_pay_sing_charge_qrcode_layout.webp new file mode 100644 index 0000000..9e652ac Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_pay_sing_charge_qrcode_layout.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_pay_sing_expire_soon_tip.9.png b/app/src/main/res/drawable-xxhdpi/bg_pay_sing_expire_soon_tip.9.png new file mode 100644 index 0000000..ff5f131 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_pay_sing_expire_soon_tip.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_pay_sing_timer.9.png b/app/src/main/res/drawable-xxhdpi/bg_pay_sing_timer.9.png new file mode 100644 index 0000000..4f67459 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_pay_sing_timer.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_paysing_recharge_view.webp b/app/src/main/res/drawable-xxhdpi/bg_paysing_recharge_view.webp new file mode 100644 index 0000000..6cd68f5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_paysing_recharge_view.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_paysing_recharge_view_tip.webp b/app/src/main/res/drawable-xxhdpi/bg_paysing_recharge_view_tip.webp new file mode 100644 index 0000000..de72b3b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_paysing_recharge_view_tip.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_place_bind.webp b/app/src/main/res/drawable-xxhdpi/bg_place_bind.webp new file mode 100644 index 0000000..47f5846 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_place_bind.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_place_bind_button.png b/app/src/main/res/drawable-xxhdpi/bg_place_bind_button.png new file mode 100644 index 0000000..3d48260 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_place_bind_button.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_pro_score_info.webp b/app/src/main/res/drawable-xxhdpi/bg_pro_score_info.webp new file mode 100644 index 0000000..65c6038 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_pro_score_info.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_score_result.webp b/app/src/main/res/drawable-xxhdpi/bg_score_result.webp new file mode 100644 index 0000000..6911faf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_score_result.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_score_result_info.webp b/app/src/main/res/drawable-xxhdpi/bg_score_result_info.webp new file mode 100644 index 0000000..4ac643e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_score_result_info.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_share_record.webp b/app/src/main/res/drawable-xxhdpi/bg_share_record.webp new file mode 100644 index 0000000..21833cd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_share_record.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_table_broadcast.webp b/app/src/main/res/drawable-xxhdpi/bg_table_broadcast.webp new file mode 100644 index 0000000..101921e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_table_broadcast.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_tv_ctrl.png b/app/src/main/res/drawable-xxhdpi/bg_tv_ctrl.png new file mode 100644 index 0000000..8255fa0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_tv_ctrl.png differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_tv_layout_song_title.webp b/app/src/main/res/drawable-xxhdpi/bg_tv_layout_song_title.webp new file mode 100644 index 0000000..7d5d210 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_tv_layout_song_title.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_tv_qrcode.webp b/app/src/main/res/drawable-xxhdpi/bg_tv_qrcode.webp new file mode 100644 index 0000000..9d992a2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_tv_qrcode.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_tv_qrcode_activate.webp b/app/src/main/res/drawable-xxhdpi/bg_tv_qrcode_activate.webp new file mode 100644 index 0000000..83840a9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_tv_qrcode_activate.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_tv_song_info.webp b/app/src/main/res/drawable-xxhdpi/bg_tv_song_info.webp new file mode 100644 index 0000000..b438496 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_tv_song_info.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_tv_volume.webp b/app/src/main/res/drawable-xxhdpi/bg_tv_volume.webp new file mode 100644 index 0000000..ec74f0a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_tv_volume.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_video.webp b/app/src/main/res/drawable-xxhdpi/bg_video.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_video.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_video_download_error.webp b/app/src/main/res/drawable-xxhdpi/bg_video_download_error.webp new file mode 100644 index 0000000..93636c3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_video_download_error.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/bg_window.webp b/app/src/main/res/drawable-xxhdpi/bg_window.webp new file mode 100644 index 0000000..ccafa69 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/bg_window.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/cover_dialog_song_recommend.webp b/app/src/main/res/drawable-xxhdpi/cover_dialog_song_recommend.webp new file mode 100644 index 0000000..9e8b32b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cover_dialog_song_recommend.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/cover_skin_blue_purple.webp b/app/src/main/res/drawable-xxhdpi/cover_skin_blue_purple.webp new file mode 100644 index 0000000..ab17cc0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cover_skin_blue_purple.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/cover_skin_circle.webp b/app/src/main/res/drawable-xxhdpi/cover_skin_circle.webp new file mode 100644 index 0000000..8065ae3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cover_skin_circle.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/cover_skin_cvte_default.webp b/app/src/main/res/drawable-xxhdpi/cover_skin_cvte_default.webp new file mode 100644 index 0000000..0a8ef72 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cover_skin_cvte_default.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/cover_skin_gallery.webp b/app/src/main/res/drawable-xxhdpi/cover_skin_gallery.webp new file mode 100644 index 0000000..dc34fef Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cover_skin_gallery.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/cover_skin_tradition.webp b/app/src/main/res/drawable-xxhdpi/cover_skin_tradition.webp new file mode 100644 index 0000000..8924cfe Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/cover_skin_tradition.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_0.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_0.webp new file mode 100644 index 0000000..879d586 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_1.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_1.webp new file mode 100644 index 0000000..6d8b927 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_2.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_2.webp new file mode 100644 index 0000000..11166e4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_3.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_3.webp new file mode 100644 index 0000000..e2b2068 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_4.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_4.webp new file mode 100644 index 0000000..f7dd951 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_band_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_0.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_0.webp new file mode 100644 index 0000000..661eb3c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_1.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_1.webp new file mode 100644 index 0000000..7691606 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_2.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_2.webp new file mode 100644 index 0000000..c4772fb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_3.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_3.webp new file mode 100644 index 0000000..fd3788a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_4.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_4.webp new file mode 100644 index 0000000..c320f53 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_female_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_0.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_0.webp new file mode 100644 index 0000000..5e586e5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_1.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_1.webp new file mode 100644 index 0000000..47140aa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_2.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_2.webp new file mode 100644 index 0000000..a5edf23 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_3.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_3.webp new file mode 100644 index 0000000..4d0ee6d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_4.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_4.webp new file mode 100644 index 0000000..c6b5937 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_male_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_0.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_0.webp new file mode 100644 index 0000000..27c048c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_1.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_1.webp new file mode 100644 index 0000000..610aab9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_2.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_2.webp new file mode 100644 index 0000000..ec11b6b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_3.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_3.webp new file mode 100644 index 0000000..9358d01 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_4.webp b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_4.webp new file mode 100644 index 0000000..ac8967c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/default_avatar_singer_unknown_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/divider_mi_guide_button.webp b/app/src/main/res/drawable-xxhdpi/divider_mi_guide_button.webp new file mode 100644 index 0000000..55f9762 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/divider_mi_guide_button.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/fire_alarm_hint.webp b/app/src/main/res/drawable-xxhdpi/fire_alarm_hint.webp new file mode 100644 index 0000000..b9b03ae Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fire_alarm_hint.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/fragmentation_help.png b/app/src/main/res/drawable-xxhdpi/fragmentation_help.png new file mode 100644 index 0000000..ef8162b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fragmentation_help.png differ diff --git a/app/src/main/res/drawable-xxhdpi/fragmentation_ic_expandable.png b/app/src/main/res/drawable-xxhdpi/fragmentation_ic_expandable.png new file mode 100644 index 0000000..a29c4a6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fragmentation_ic_expandable.png differ diff --git a/app/src/main/res/drawable-xxhdpi/fragmentation_ic_right.png b/app/src/main/res/drawable-xxhdpi/fragmentation_ic_right.png new file mode 100644 index 0000000..99dcfd1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fragmentation_ic_right.png differ diff --git a/app/src/main/res/drawable-xxhdpi/fragmentation_ic_stack.png b/app/src/main/res/drawable-xxhdpi/fragmentation_ic_stack.png new file mode 100644 index 0000000..3eb8072 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/fragmentation_ic_stack.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_123.png b/app/src/main/res/drawable-xxhdpi/ic_123.png new file mode 100644 index 0000000..849d953 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_123.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_abc.png b/app/src/main/res/drawable-xxhdpi/ic_abc.png new file mode 100644 index 0000000..72ba4e2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_abc.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_abc_lower.png b/app/src/main/res/drawable-xxhdpi/ic_abc_lower.png new file mode 100644 index 0000000..c98488a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_abc_lower.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_abc_upper.png b/app/src/main/res/drawable-xxhdpi/ic_abc_upper.png new file mode 100644 index 0000000..97afe08 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_abc_upper.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_add_to_top.png b/app/src/main/res/drawable-xxhdpi/ic_add_to_top.png new file mode 100644 index 0000000..67a233a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add_to_top.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_appstore_not_install.webp b/app/src/main/res/drawable-xxhdpi/ic_appstore_not_install.webp new file mode 100644 index 0000000..51091d1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_appstore_not_install.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_back.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_back.png new file mode 100644 index 0000000..753bd5e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_back.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_next.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_next.png new file mode 100644 index 0000000..9eea0f0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_next.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_pre.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_pre.png new file mode 100644 index 0000000..f015f67 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_pre.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_charge_four.png b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_four.png new file mode 100644 index 0000000..c05010d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_four.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_charge_low.png b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_low.png new file mode 100644 index 0000000..ed1530d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_low.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_charge_one.png b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_one.png new file mode 100644 index 0000000..1bc68fb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_one.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_charge_three.png b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_three.png new file mode 100644 index 0000000..7f26ff9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_three.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_charge_two.png b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_two.png new file mode 100644 index 0000000..bbb7778 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_charge_two.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_four.png b/app/src/main/res/drawable-xxhdpi/ic_battery_four.png new file mode 100644 index 0000000..568b5c9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_four.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_low.png b/app/src/main/res/drawable-xxhdpi/ic_battery_low.png new file mode 100644 index 0000000..d7e0297 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_low.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_one.png b/app/src/main/res/drawable-xxhdpi/ic_battery_one.png new file mode 100644 index 0000000..5b96427 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_one.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_three.png b/app/src/main/res/drawable-xxhdpi/ic_battery_three.png new file mode 100644 index 0000000..3b9ed1e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_three.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_battery_two.png b/app/src/main/res/drawable-xxhdpi/ic_battery_two.png new file mode 100644 index 0000000..81c6055 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_battery_two.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_call_room_service.png b/app/src/main/res/drawable-xxhdpi/ic_call_room_service.png new file mode 100644 index 0000000..b059e18 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_call_room_service.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_cancel_room_service.png b/app/src/main/res/drawable-xxhdpi/ic_cancel_room_service.png new file mode 100644 index 0000000..a1041e7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_cancel_room_service.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close.png b/app/src/main/res/drawable-xxhdpi/ic_close.png new file mode 100644 index 0000000..7eda961 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_confirm.png b/app/src/main/res/drawable-xxhdpi/ic_confirm.png new file mode 100644 index 0000000..8a08374 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_confirm.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_atmosphere.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_atmosphere.png new file mode 100644 index 0000000..9a6bb33 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_atmosphere.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_item_selected.webp b/app/src/main/res/drawable-xxhdpi/ic_ctrl_item_selected.webp new file mode 100644 index 0000000..2a9dd97 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_item_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_mi.webp b/app/src/main/res/drawable-xxhdpi/ic_ctrl_mi.webp new file mode 100644 index 0000000..c80d6d1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_mi.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_mobile.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_mobile.png new file mode 100644 index 0000000..9b4dc7d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_mobile.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_music_accomp.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_music_accomp.png new file mode 100644 index 0000000..e4967c1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_music_accomp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_music_origin.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_music_origin.png new file mode 100644 index 0000000..8f76867 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_music_origin.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_orderlist.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_orderlist.png new file mode 100644 index 0000000..5ce470a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_orderlist.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_pause.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_pause.png new file mode 100644 index 0000000..e4e05e4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_pause.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_phantom.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_phantom.png new file mode 100644 index 0000000..7cb3f88 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_phantom.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_play.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_play.png new file mode 100644 index 0000000..44ca3be Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_replay.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_replay.png new file mode 100644 index 0000000..1c956ad Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_replay.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_sound_disable.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_sound_disable.png new file mode 100644 index 0000000..0c16f87 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_sound_disable.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_sound_enable.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_sound_enable.png new file mode 100644 index 0000000..f0b16c5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_sound_enable.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_switch_song.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_switch_song.png new file mode 100644 index 0000000..239747d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_switch_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tone.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tone.png new file mode 100644 index 0000000..b9f25c2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tone.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_accomp.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_accomp.png new file mode 100644 index 0000000..f941605 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_accomp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_auto_light_off.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_auto_light_off.png new file mode 100644 index 0000000..a7d1b33 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_auto_light_off.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_auto_light_on.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_auto_light_on.png new file mode 100644 index 0000000..69f04c1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_auto_light_on.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_close_sound.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_close_sound.png new file mode 100644 index 0000000..815b502 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_close_sound.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_blue.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_blue.png new file mode 100644 index 0000000..d260c39 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_blue.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_classic.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_classic.png new file mode 100644 index 0000000..c572979 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_classic.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_country.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_country.png new file mode 100644 index 0000000..4e06d64 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_country.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_electric.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_electric.png new file mode 100644 index 0000000..81b42ca Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_electric.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_human.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_human.png new file mode 100644 index 0000000..c0263c8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_human.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_jazz.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_jazz.png new file mode 100644 index 0000000..395bdf6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_jazz.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_rock.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_rock.png new file mode 100644 index 0000000..a77f52a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_rock.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_slow.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_slow.png new file mode 100644 index 0000000..fb144bb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_eq_slow.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_1.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_1.png new file mode 100644 index 0000000..fd08790 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_1.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_2.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_2.png new file mode 100644 index 0000000..b236fd4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_2.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_3.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_3.png new file mode 100644 index 0000000..2b0d717 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_3.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_4.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_4.png new file mode 100644 index 0000000..994f8b7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_4.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_5.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_5.png new file mode 100644 index 0000000..60a39a6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_5.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_6.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_6.png new file mode 100644 index 0000000..4696dc5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_6.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_7.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_7.png new file mode 100644 index 0000000..312a595 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_7.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_8.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_8.png new file mode 100644 index 0000000..2285c3d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_light_8.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mic_decr.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mic_decr.png new file mode 100644 index 0000000..bed8678 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mic_decr.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mic_incr.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mic_incr.png new file mode 100644 index 0000000..e53c429 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mic_incr.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mixed_decr.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mixed_decr.png new file mode 100644 index 0000000..421a49e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mixed_decr.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mixed_incr.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mixed_incr.png new file mode 100644 index 0000000..d68b3b4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_mixed_incr.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_origin.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_origin.png new file mode 100644 index 0000000..1bb100b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_origin.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_pause.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_pause.png new file mode 100644 index 0000000..2d804a3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_pause.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_play.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_play.png new file mode 100644 index 0000000..de38176 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_1.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_1.png new file mode 100644 index 0000000..cf83ee1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_1.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_2.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_2.png new file mode 100644 index 0000000..b79db0d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_2.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_3.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_3.png new file mode 100644 index 0000000..e045dc5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_3.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_4.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_4.png new file mode 100644 index 0000000..a13a97f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_4.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_5.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_5.png new file mode 100644 index 0000000..8cb2fee Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_5.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_decr.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_decr.png new file mode 100644 index 0000000..603031c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_decr.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_female.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_female.png new file mode 100644 index 0000000..8ae0c0d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_female.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_incr.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_incr.png new file mode 100644 index 0000000..0dc6e39 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_incr.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_male.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_male.png new file mode 100644 index 0000000..78ec060 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_male.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_standard.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_standard.png new file mode 100644 index 0000000..287b69c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_tv_tone_standard.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_volume_minus.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_volume_minus.png new file mode 100644 index 0000000..584c753 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_volume_minus.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ctrl_volume_plus.png b/app/src/main/res/drawable-xxhdpi/ic_ctrl_volume_plus.png new file mode 100644 index 0000000..d2eea13 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ctrl_volume_plus.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_default_appstore_logo.webp b/app/src/main/res/drawable-xxhdpi/ic_default_appstore_logo.webp new file mode 100644 index 0000000..e6d874c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_default_appstore_logo.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_default_movie_avatar.webp b/app/src/main/res/drawable-xxhdpi/ic_default_movie_avatar.webp new file mode 100644 index 0000000..2997839 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_default_movie_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_default_movie_cover.webp b/app/src/main/res/drawable-xxhdpi/ic_default_movie_cover.webp new file mode 100644 index 0000000..32f9d40 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_default_movie_cover.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_default_playlist_avatar.webp b/app/src/main/res/drawable-xxhdpi/ic_default_playlist_avatar.webp new file mode 100644 index 0000000..7953e1e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_default_playlist_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_default_rank_avatar.webp b/app/src/main/res/drawable-xxhdpi/ic_default_rank_avatar.webp new file mode 100644 index 0000000..c0ad2bf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_default_rank_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_default_singer_avatar.webp b/app/src/main/res/drawable-xxhdpi/ic_default_singer_avatar.webp new file mode 100644 index 0000000..0c2090c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_default_singer_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_delete.png b/app/src/main/res/drawable-xxhdpi/ic_delete.png new file mode 100644 index 0000000..5fab743 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_device.webp b/app/src/main/res/drawable-xxhdpi/ic_device.webp new file mode 100644 index 0000000..9652cca Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_device.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_device_disable.webp b/app/src/main/res/drawable-xxhdpi/ic_device_disable.webp new file mode 100644 index 0000000..d05af1e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_device_disable.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_download.png b/app/src/main/res/drawable-xxhdpi/ic_download.png new file mode 100644 index 0000000..5b7d983 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_download.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_download_retry.png b/app/src/main/res/drawable-xxhdpi/ic_download_retry.png new file mode 100644 index 0000000..d4017d5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_download_retry.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drop_down.png b/app/src/main/res/drawable-xxhdpi/ic_drop_down.png new file mode 100644 index 0000000..e089ba3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drop_down.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drop_down_gray.png b/app/src/main/res/drawable-xxhdpi/ic_drop_down_gray.png new file mode 100644 index 0000000..e089ba3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drop_down_gray.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drop_left.png b/app/src/main/res/drawable-xxhdpi/ic_drop_left.png new file mode 100644 index 0000000..967ca5d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drop_left.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drop_right.png b/app/src/main/res/drawable-xxhdpi/ic_drop_right.png new file mode 100644 index 0000000..b0d3e26 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drop_right.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_drop_up.png b/app/src/main/res/drawable-xxhdpi/ic_drop_up.png new file mode 100644 index 0000000..8d23276 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_drop_up.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_blue.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_blue.webp new file mode 100644 index 0000000..aeb2a99 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_blue.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_classic.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_classic.webp new file mode 100644 index 0000000..632674d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_classic.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_country.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_country.webp new file mode 100644 index 0000000..14b2758 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_country.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_electric.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_electric.webp new file mode 100644 index 0000000..57b2951 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_electric.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_human.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_human.webp new file mode 100644 index 0000000..6f43391 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_human.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_jazz.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_jazz.webp new file mode 100644 index 0000000..fcfd2f0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_jazz.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_rock.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_rock.webp new file mode 100644 index 0000000..cb7f19e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_rock.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_eq_slow.webp b/app/src/main/res/drawable-xxhdpi/ic_eq_slow.webp new file mode 100644 index 0000000..d6fb89f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_eq_slow.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_favorite.png b/app/src/main/res/drawable-xxhdpi/ic_favorite.png new file mode 100644 index 0000000..cb38f8f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_favorite.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_filter.webp b/app/src/main/res/drawable-xxhdpi/ic_filter.webp new file mode 100644 index 0000000..e9ae7fa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_filter.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_kb_clear.png b/app/src/main/res/drawable-xxhdpi/ic_kb_clear.png new file mode 100644 index 0000000..af31ff1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_kb_clear.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_kb_clear_selected.png b/app/src/main/res/drawable-xxhdpi/ic_kb_clear_selected.png new file mode 100644 index 0000000..a47a428 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_kb_clear_selected.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_key_delete.png b/app/src/main/res/drawable-xxhdpi/ic_key_delete.png new file mode 100644 index 0000000..65218cb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_key_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_key_delete_golden.png b/app/src/main/res/drawable-xxhdpi/ic_key_delete_golden.png new file mode 100644 index 0000000..5f737af Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_key_delete_golden.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launch_logo.webp b/app/src/main/res/drawable-xxhdpi/ic_launch_logo.webp new file mode 100644 index 0000000..855f781 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launch_logo.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..c207c73 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_beautiful.webp b/app/src/main/res/drawable-xxhdpi/ic_light_beautiful.webp new file mode 100644 index 0000000..1f00995 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_beautiful.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_bright.webp b/app/src/main/res/drawable-xxhdpi/ic_light_bright.webp new file mode 100644 index 0000000..b0b1c0e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_bright.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_dim.webp b/app/src/main/res/drawable-xxhdpi/ic_light_dim.webp new file mode 100644 index 0000000..a99ee8f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_dim.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_lyrical.webp b/app/src/main/res/drawable-xxhdpi/ic_light_lyrical.webp new file mode 100644 index 0000000..b66747a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_lyrical.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_rock.webp b/app/src/main/res/drawable-xxhdpi/ic_light_rock.webp new file mode 100644 index 0000000..56d14a4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_rock.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_sad.webp b/app/src/main/res/drawable-xxhdpi/ic_light_sad.webp new file mode 100644 index 0000000..6799c9d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_sad.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_slow_rock.webp b/app/src/main/res/drawable-xxhdpi/ic_light_slow_rock.webp new file mode 100644 index 0000000..b8df08d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_slow_rock.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_light_soft.webp b/app/src/main/res/drawable-xxhdpi/ic_light_soft.webp new file mode 100644 index 0000000..7a964dc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_light_soft.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_loading.png b/app/src/main/res/drawable-xxhdpi/ic_loading.png new file mode 100644 index 0000000..5606aa3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_loading.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_logo.png b/app/src/main/res/drawable-xxhdpi/ic_logo.png new file mode 100644 index 0000000..3d62f07 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_logo.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_logo_white.png b/app/src/main/res/drawable-xxhdpi/ic_logo_white.png new file mode 100644 index 0000000..3d62f07 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_logo_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_search.png b/app/src/main/res/drawable-xxhdpi/ic_menu_search.png new file mode 100644 index 0000000..24b8b25 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_settings.png b/app/src/main/res/drawable-xxhdpi/ic_menu_settings.png new file mode 100644 index 0000000..d450c30 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_settings.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_shutdown.png b/app/src/main/res/drawable-xxhdpi/ic_menu_shutdown.png new file mode 100644 index 0000000..3902fd8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_shutdown.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_skin.png b/app/src/main/res/drawable-xxhdpi/ic_menu_skin.png new file mode 100644 index 0000000..287560a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_skin.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_wifi_account.png b/app/src/main/res/drawable-xxhdpi/ic_menu_wifi_account.png new file mode 100644 index 0000000..44bac59 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_wifi_account.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mi_ai_logo.webp b/app/src/main/res/drawable-xxhdpi/ic_mi_ai_logo.webp new file mode 100644 index 0000000..6b8afc7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mi_ai_logo.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mi_guide_back.png b/app/src/main/res/drawable-xxhdpi/ic_mi_guide_back.png new file mode 100644 index 0000000..753bd5e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mi_guide_back.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mi_guide_dialog_close.png b/app/src/main/res/drawable-xxhdpi/ic_mi_guide_dialog_close.png new file mode 100644 index 0000000..7eda961 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mi_guide_dialog_close.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_minus.png b/app/src/main/res/drawable-xxhdpi/ic_minus.png new file mode 100644 index 0000000..adc84a5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_minus.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mobile_arrow.webp b/app/src/main/res/drawable-xxhdpi/ic_mobile_arrow.webp new file mode 100644 index 0000000..6349f00 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mobile_arrow.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mobile_upload_song.png b/app/src/main/res/drawable-xxhdpi/ic_mobile_upload_song.png new file mode 100644 index 0000000..961c217 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mobile_upload_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_more.png b/app/src/main/res/drawable-xxhdpi/ic_more.png new file mode 100644 index 0000000..653ee75 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_more.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_movie_play.webp b/app/src/main/res/drawable-xxhdpi/ic_movie_play.webp new file mode 100644 index 0000000..e83886c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_movie_play.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_number_popup_delete.png b/app/src/main/res/drawable-xxhdpi/ic_number_popup_delete.png new file mode 100644 index 0000000..a4dc96a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_number_popup_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_order_song.png b/app/src/main/res/drawable-xxhdpi/ic_order_song.png new file mode 100644 index 0000000..c0483ec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_order_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_ott_delete.png b/app/src/main/res/drawable-xxhdpi/ic_ott_delete.png new file mode 100644 index 0000000..8a202a3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_ott_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_page_next.webp b/app/src/main/res/drawable-xxhdpi/ic_page_next.webp new file mode 100644 index 0000000..7d3d0a1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_page_next.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_page_pre.webp b/app/src/main/res/drawable-xxhdpi/ic_page_pre.webp new file mode 100644 index 0000000..3e40a46 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_page_pre.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pause.png b/app/src/main/res/drawable-xxhdpi/ic_pause.png new file mode 100644 index 0000000..96fc7b5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pinyin_next.png b/app/src/main/res/drawable-xxhdpi/ic_pinyin_next.png new file mode 100644 index 0000000..bb4f582 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pinyin_next.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playing.webp b/app/src/main/res/drawable-xxhdpi/ic_playing.webp new file mode 100644 index 0000000..fd0f6b6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playing.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_plus.png b/app/src/main/res/drawable-xxhdpi/ic_plus.png new file mode 100644 index 0000000..aa23a89 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_plus.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_random.png b/app/src/main/res/drawable-xxhdpi/ic_random.png new file mode 100644 index 0000000..7bae6f3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_random.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_rank_order_1.webp b/app/src/main/res/drawable-xxhdpi/ic_rank_order_1.webp new file mode 100644 index 0000000..5550fcb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_rank_order_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_rank_order_2.webp b/app/src/main/res/drawable-xxhdpi/ic_rank_order_2.webp new file mode 100644 index 0000000..498f60e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_rank_order_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_rank_order_3.webp b/app/src/main/res/drawable-xxhdpi/ic_rank_order_3.webp new file mode 100644 index 0000000..0558e25 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_rank_order_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_record.png b/app/src/main/res/drawable-xxhdpi/ic_record.png new file mode 100644 index 0000000..65e1739 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_record.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_recording.png b/app/src/main/res/drawable-xxhdpi/ic_recording.png new file mode 100644 index 0000000..265f28e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_recording.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_reset.png b/app/src/main/res/drawable-xxhdpi/ic_reset.png new file mode 100644 index 0000000..a1f412f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_reset.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_room_service_cup.webp b/app/src/main/res/drawable-xxhdpi/ic_room_service_cup.webp new file mode 100644 index 0000000..e4c872b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_room_service_cup.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_room_service_dice.webp b/app/src/main/res/drawable-xxhdpi/ic_room_service_dice.webp new file mode 100644 index 0000000..f6ca3a5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_room_service_dice.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_room_service_mic.webp b/app/src/main/res/drawable-xxhdpi/ic_room_service_mic.webp new file mode 100644 index 0000000..2cdcd44 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_room_service_mic.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_room_service_pay_bill.webp b/app/src/main/res/drawable-xxhdpi/ic_room_service_pay_bill.webp new file mode 100644 index 0000000..a6433ae Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_room_service_pay_bill.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_room_service_reminder.webp b/app/src/main/res/drawable-xxhdpi/ic_room_service_reminder.webp new file mode 100644 index 0000000..64d6694 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_room_service_reminder.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_room_service_waiter.webp b/app/src/main/res/drawable-xxhdpi/ic_room_service_waiter.webp new file mode 100644 index 0000000..0742573 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_room_service_waiter.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_champion.webp b/app/src/main/res/drawable-xxhdpi/ic_score_champion.webp new file mode 100644 index 0000000..f8de87b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_champion.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_champion_bottom.webp b/app/src/main/res/drawable-xxhdpi/ic_score_champion_bottom.webp new file mode 100644 index 0000000..392c371 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_champion_bottom.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_default_avatar.webp b/app/src/main/res/drawable-xxhdpi/ic_score_default_avatar.webp new file mode 100644 index 0000000..bbe35ee Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_default_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_level_a.webp b/app/src/main/res/drawable-xxhdpi/ic_score_level_a.webp new file mode 100644 index 0000000..afaf51b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_level_a.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_level_b.webp b/app/src/main/res/drawable-xxhdpi/ic_score_level_b.webp new file mode 100644 index 0000000..1ae2ff6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_level_b.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_level_c.webp b/app/src/main/res/drawable-xxhdpi/ic_score_level_c.webp new file mode 100644 index 0000000..511bfe2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_level_c.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_level_d.webp b/app/src/main/res/drawable-xxhdpi/ic_score_level_d.webp new file mode 100644 index 0000000..2bfe924 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_level_d.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_level_s.webp b/app/src/main/res/drawable-xxhdpi/ic_score_level_s.webp new file mode 100644 index 0000000..737e3dc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_level_s.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_level_ss.webp b/app/src/main/res/drawable-xxhdpi/ic_score_level_ss.webp new file mode 100644 index 0000000..34ff6a3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_level_ss.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_level_sss.webp b/app/src/main/res/drawable-xxhdpi/ic_score_level_sss.webp new file mode 100644 index 0000000..e56dd26 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_level_sss.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_none_user_avatar.webp b/app/src/main/res/drawable-xxhdpi/ic_score_none_user_avatar.webp new file mode 100644 index 0000000..2f5be5b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_none_user_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_switch_disable.webp b/app/src/main/res/drawable-xxhdpi/ic_score_switch_disable.webp new file mode 100644 index 0000000..7334759 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_switch_disable.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_score_switch_enable.webp b/app/src/main/res/drawable-xxhdpi/ic_score_switch_enable.webp new file mode 100644 index 0000000..1c7b95a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_score_switch_enable.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_screen_adjust.png b/app/src/main/res/drawable-xxhdpi/ic_screen_adjust.png new file mode 100644 index 0000000..c0992a0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_screen_adjust.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_screen_adjust_move.png b/app/src/main/res/drawable-xxhdpi/ic_screen_adjust_move.png new file mode 100644 index 0000000..f3d0504 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_screen_adjust_move.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_search.webp b/app/src/main/res/drawable-xxhdpi/ic_search.webp new file mode 100644 index 0000000..37c37ae Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_search_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_search_24dp.png new file mode 100644 index 0000000..bb947bd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_search_delete.webp b/app/src/main/res/drawable-xxhdpi/ic_search_delete.webp new file mode 100644 index 0000000..28a991e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_search_delete.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_share.webp b/app/src/main/res/drawable-xxhdpi/ic_share.webp new file mode 100644 index 0000000..4167025 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_share.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_sing_guide.png b/app/src/main/res/drawable-xxhdpi/ic_sing_guide.png new file mode 100644 index 0000000..caef2ce Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_sing_guide.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_song_title.webp b/app/src/main/res/drawable-xxhdpi/ic_song_title.webp new file mode 100644 index 0000000..e87c101 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_song_title.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_song_title_default_avatar.webp b/app/src/main/res/drawable-xxhdpi/ic_song_title_default_avatar.webp new file mode 100644 index 0000000..bbe35ee Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_song_title_default_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_song_title_left.webp b/app/src/main/res/drawable-xxhdpi/ic_song_title_left.webp new file mode 100644 index 0000000..d1774b1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_song_title_left.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_song_title_right.webp b/app/src/main/res/drawable-xxhdpi/ic_song_title_right.webp new file mode 100644 index 0000000..50f26a6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_song_title_right.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_song_title_top.webp b/app/src/main/res/drawable-xxhdpi/ic_song_title_top.webp new file mode 100644 index 0000000..ff2e39c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_song_title_top.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_space.png b/app/src/main/res/drawable-xxhdpi/ic_space.png new file mode 100644 index 0000000..f2c235a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_space.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star_dark.webp b/app/src/main/res/drawable-xxhdpi/ic_star_dark.webp new file mode 100644 index 0000000..a1f38b6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_star_dark.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star_half.webp b/app/src/main/res/drawable-xxhdpi/ic_star_half.webp new file mode 100644 index 0000000..6f05c54 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_star_half.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_star_light.webp b/app/src/main/res/drawable-xxhdpi/ic_star_light.webp new file mode 100644 index 0000000..4214a95 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_star_light.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_system_back.png b/app/src/main/res/drawable-xxhdpi/ic_system_back.png new file mode 100644 index 0000000..4f1c140 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_system_back.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tone_chant.webp b/app/src/main/res/drawable-xxhdpi/ic_tone_chant.webp new file mode 100644 index 0000000..79ced5b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tone_chant.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tone_lyrical.webp b/app/src/main/res/drawable-xxhdpi/ic_tone_lyrical.webp new file mode 100644 index 0000000..4892af4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tone_lyrical.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tone_pop.webp b/app/src/main/res/drawable-xxhdpi/ic_tone_pop.webp new file mode 100644 index 0000000..d8850ce Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tone_pop.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tone_rock.webp b/app/src/main/res/drawable-xxhdpi/ic_tone_rock.webp new file mode 100644 index 0000000..852f0bd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tone_rock.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tone_singer.webp b/app/src/main/res/drawable-xxhdpi/ic_tone_singer.webp new file mode 100644 index 0000000..494c4c2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tone_singer.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_top.png b/app/src/main/res/drawable-xxhdpi/ic_top.png new file mode 100644 index 0000000..1c2ea08 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_top.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_touch_correct.png b/app/src/main/res/drawable-xxhdpi/ic_touch_correct.png new file mode 100644 index 0000000..71275ec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_touch_correct.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tv_singer_default.webp b/app/src/main/res/drawable-xxhdpi/ic_tv_singer_default.webp new file mode 100644 index 0000000..b32b1fc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_tv_singer_default.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_weixin_logo.png b/app/src/main/res/drawable-xxhdpi/ic_weixin_logo.png new file mode 100644 index 0000000..539b607 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_weixin_logo.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_wifi_off.png b/app/src/main/res/drawable-xxhdpi/ic_wifi_off.png new file mode 100644 index 0000000..ab1b394 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_wifi_off.png differ diff --git a/app/src/main/res/drawable-xxhdpi/lrc_play.png b/app/src/main/res/drawable-xxhdpi/lrc_play.png new file mode 100644 index 0000000..b7ed9ec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/lrc_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/no_banner.png b/app/src/main/res/drawable-xxhdpi/no_banner.png new file mode 100644 index 0000000..14a8a13 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/no_banner.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_bg_low_normal.9.png b/app/src/main/res/drawable-xxhdpi/notification_bg_low_normal.9.png new file mode 100644 index 0000000..766620a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_bg_low_normal.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_bg_low_pressed.9.png b/app/src/main/res/drawable-xxhdpi/notification_bg_low_pressed.9.png new file mode 100644 index 0000000..cfd771d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_bg_low_pressed.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_bg_normal.9.png b/app/src/main/res/drawable-xxhdpi/notification_bg_normal.9.png new file mode 100644 index 0000000..b4a987a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_bg_normal.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_bg_normal_pressed.9.png b/app/src/main/res/drawable-xxhdpi/notification_bg_normal_pressed.9.png new file mode 100644 index 0000000..e218739 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_bg_normal_pressed.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notify_panel_notification_icon_bg.png b/app/src/main/res/drawable-xxhdpi/notify_panel_notification_icon_bg.png new file mode 100644 index 0000000..5f668a5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notify_panel_notification_icon_bg.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_activate_dialog.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_activate_dialog.webp new file mode 100644 index 0000000..6cf043c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_activate_dialog.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_avatar.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_avatar.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_charge_dialog.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_charge_dialog.webp new file mode 100644 index 0000000..82565d1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_charge_dialog.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_ctrl_orderlist.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_ctrl_orderlist.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_ctrl_orderlist.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_ctrl_volume.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_ctrl_volume.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_ctrl_volume.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_disco_item_selected.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_disco_item_selected.webp new file mode 100644 index 0000000..1094e80 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_disco_item_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_gift_code_guide_default.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_gift_code_guide_default.webp new file mode 100644 index 0000000..20a35ba Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_gift_code_guide_default.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_input_type_container.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_input_type_container.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_input_type_container.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_keyboard.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_keyboard.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_keyboard.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_license_info.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_license_info.webp new file mode 100644 index 0000000..52517e2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_license_info.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_dialog.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_dialog.webp new file mode 100644 index 0000000..29424f1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_dialog.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_qrcode.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_qrcode.webp new file mode 100644 index 0000000..2e623b0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_qrcode.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_success_dialog.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_success_dialog.webp new file mode 100644 index 0000000..74e78bb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_recharge_success_dialog.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_router.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_router.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_router.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_user_mv_tip_item.9.png b/app/src/main/res/drawable-xxhdpi/ott_bg_user_mv_tip_item.9.png new file mode 100644 index 0000000..17149ab Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_user_mv_tip_item.9.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_vip_experience_dialog.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_vip_experience_dialog.webp new file mode 100644 index 0000000..1674757 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_vip_experience_dialog.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_bg_wifi_status.webp b/app/src/main/res/drawable-xxhdpi/ott_bg_wifi_status.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_bg_wifi_status.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_border_video.webp b/app/src/main/res/drawable-xxhdpi/ott_border_video.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_border_video.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_about_us.png b/app/src/main/res/drawable-xxhdpi/ott_ic_about_us.png new file mode 100644 index 0000000..388a248 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_about_us.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_agreement.png b/app/src/main/res/drawable-xxhdpi/ott_ic_agreement.png new file mode 100644 index 0000000..118da57 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_agreement.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ai.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ai.png new file mode 100644 index 0000000..10727d1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ai.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_arrow_down.png b/app/src/main/res/drawable-xxhdpi/ott_ic_arrow_down.png new file mode 100644 index 0000000..cf4773d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_arrow_down.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_arrow_up.png b/app/src/main/res/drawable-xxhdpi/ott_ic_arrow_up.png new file mode 100644 index 0000000..51b3543 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_arrow_up.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_auto_delete.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_auto_delete.webp new file mode 100644 index 0000000..ffab620 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_auto_delete.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_auto_remove_space.png b/app/src/main/res/drawable-xxhdpi/ott_ic_auto_remove_space.png new file mode 100644 index 0000000..b0f1bf9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_auto_remove_space.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_back.png b/app/src/main/res/drawable-xxhdpi/ott_ic_back.png new file mode 100644 index 0000000..282fbea Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_back.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_bd_ai_logo.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_bd_ai_logo.webp new file mode 100644 index 0000000..e32ec07 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_bd_ai_logo.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_boot_logo.png b/app/src/main/res/drawable-xxhdpi/ott_ic_boot_logo.png new file mode 100644 index 0000000..e855be0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_boot_logo.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_checkmark.png b/app/src/main/res/drawable-xxhdpi/ott_ic_checkmark.png new file mode 100644 index 0000000..560c731 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_checkmark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_clear_recent.png b/app/src/main/res/drawable-xxhdpi/ott_ic_clear_recent.png new file mode 100644 index 0000000..4f9e7bd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_clear_recent.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_count.png b/app/src/main/res/drawable-xxhdpi/ott_ic_count.png new file mode 100644 index 0000000..500a91f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_count.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_music_accomp.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_music_accomp.png new file mode 100644 index 0000000..430f3ae Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_music_accomp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_music_origin.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_music_origin.png new file mode 100644 index 0000000..d36e6fb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_music_origin.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_orderlist.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_orderlist.png new file mode 100644 index 0000000..2ee1e1c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_orderlist.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_pause.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_pause.png new file mode 100644 index 0000000..47f0ed2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_pause.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_play.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_play.png new file mode 100644 index 0000000..7ddbdf9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_replay.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_replay.png new file mode 100644 index 0000000..dec594d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_replay.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_score.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_score.png new file mode 100644 index 0000000..a0210f4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_score.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_switch_song.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_switch_song.png new file mode 100644 index 0000000..56a13a8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_switch_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_volume.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_volume.png new file mode 100644 index 0000000..0ca927d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ctrl_volume.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_data_reset.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_data_reset.webp new file mode 100644 index 0000000..f8a4af4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_data_reset.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_data_setting.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_data_setting.webp new file mode 100644 index 0000000..b4d5380 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_data_setting.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_data_upgrade.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_data_upgrade.webp new file mode 100644 index 0000000..38f35a1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_data_upgrade.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_delete.png b/app/src/main/res/drawable-xxhdpi/ott_ic_delete.png new file mode 100644 index 0000000..b222a63 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_dir.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_dir.webp new file mode 100644 index 0000000..0a04346 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_dir.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_double_screen.png b/app/src/main/res/drawable-xxhdpi/ott_ic_double_screen.png new file mode 100644 index 0000000..79cdfdf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_double_screen.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_download.png b/app/src/main/res/drawable-xxhdpi/ott_ic_download.png new file mode 100644 index 0000000..1f28a3e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_download.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_download_path.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_download_path.webp new file mode 100644 index 0000000..1e6343c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_download_path.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_download_setting.png b/app/src/main/res/drawable-xxhdpi/ott_ic_download_setting.png new file mode 100644 index 0000000..d7e9553 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_download_setting.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ethernet_disconnect.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ethernet_disconnect.png new file mode 100644 index 0000000..bd481b8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ethernet_disconnect.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_ethernet_setting.png b/app/src/main/res/drawable-xxhdpi/ott_ic_ethernet_setting.png new file mode 100644 index 0000000..57f5d01 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_ethernet_setting.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_exit.png b/app/src/main/res/drawable-xxhdpi/ott_ic_exit.png new file mode 100644 index 0000000..95c9f7b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_exit.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_favorite.png b/app/src/main/res/drawable-xxhdpi/ott_ic_favorite.png new file mode 100644 index 0000000..8f688cf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_favorite.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_favorite_cancel.png b/app/src/main/res/drawable-xxhdpi/ott_ic_favorite_cancel.png new file mode 100644 index 0000000..65ff971 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_favorite_cancel.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_file.png b/app/src/main/res/drawable-xxhdpi/ott_ic_file.png new file mode 100644 index 0000000..a9a50bf Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_file.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_file_not_exist.png b/app/src/main/res/drawable-xxhdpi/ott_ic_file_not_exist.png new file mode 100644 index 0000000..89c5175 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_file_not_exist.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_fullscreen.png b/app/src/main/res/drawable-xxhdpi/ott_ic_fullscreen.png new file mode 100644 index 0000000..f2c4204 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_fullscreen.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_gift.png b/app/src/main/res/drawable-xxhdpi/ott_ic_gift.png new file mode 100644 index 0000000..e387c0a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_gift.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_info.png b/app/src/main/res/drawable-xxhdpi/ott_ic_info.png new file mode 100644 index 0000000..ef5fe6c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_info.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_kb_clear.png b/app/src/main/res/drawable-xxhdpi/ott_ic_kb_clear.png new file mode 100644 index 0000000..1964243 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_kb_clear.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_kb_delete.png b/app/src/main/res/drawable-xxhdpi/ott_ic_kb_delete.png new file mode 100644 index 0000000..314626f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_kb_delete.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_language.png b/app/src/main/res/drawable-xxhdpi/ott_ic_language.png new file mode 100644 index 0000000..ab204eb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_language.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_last_dir.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_last_dir.webp new file mode 100644 index 0000000..6541971 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_last_dir.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_license.png b/app/src/main/res/drawable-xxhdpi/ott_ic_license.png new file mode 100644 index 0000000..7a82f2c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_license.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_license_dark.png b/app/src/main/res/drawable-xxhdpi/ott_ic_license_dark.png new file mode 100644 index 0000000..3f59336 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_license_dark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_marquee_setting.png b/app/src/main/res/drawable-xxhdpi/ott_ic_marquee_setting.png new file mode 100644 index 0000000..8df1553 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_marquee_setting.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_media.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_media.webp new file mode 100644 index 0000000..7964992 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_media.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_minus.png b/app/src/main/res/drawable-xxhdpi/ott_ic_minus.png new file mode 100644 index 0000000..a592dcb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_minus.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_mobile_qrcode.png b/app/src/main/res/drawable-xxhdpi/ott_ic_mobile_qrcode.png new file mode 100644 index 0000000..50f6d8e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_mobile_qrcode.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_more.png b/app/src/main/res/drawable-xxhdpi/ott_ic_more.png new file mode 100644 index 0000000..10559ce Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_more.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_more_top.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_more_top.webp new file mode 100644 index 0000000..ef24b40 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_more_top.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_mute.png b/app/src/main/res/drawable-xxhdpi/ott_ic_mute.png new file mode 100644 index 0000000..014619e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_mute.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_network.png b/app/src/main/res/drawable-xxhdpi/ott_ic_network.png new file mode 100644 index 0000000..fda1e1e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_network.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_network_disconnect.png b/app/src/main/res/drawable-xxhdpi/ott_ic_network_disconnect.png new file mode 100644 index 0000000..09bfd93 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_network_disconnect.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_network_test.png b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test.png new file mode 100644 index 0000000..a8fc3eb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_fail.png b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_fail.png new file mode 100644 index 0000000..856e2ed Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_fail.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_ok.png b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_ok.png new file mode 100644 index 0000000..7a36518 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_ok.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_waiting.png b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_waiting.png new file mode 100644 index 0000000..8734ba4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_network_test_waiting.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_female.png b/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_female.png new file mode 100644 index 0000000..6f1acb8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_female.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_male.png b/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_male.png new file mode 100644 index 0000000..1c9199b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_male.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_reset.png b/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_reset.png new file mode 100644 index 0000000..c689f04 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_pitch_reset.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_play.png b/app/src/main/res/drawable-xxhdpi/ott_ic_play.png new file mode 100644 index 0000000..44ca3be Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_play_while_download.png b/app/src/main/res/drawable-xxhdpi/ott_ic_play_while_download.png new file mode 100644 index 0000000..18e11fe Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_play_while_download.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_player_state_error.png b/app/src/main/res/drawable-xxhdpi/ott_ic_player_state_error.png new file mode 100644 index 0000000..87701da Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_player_state_error.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_playing.png b/app/src/main/res/drawable-xxhdpi/ott_ic_playing.png new file mode 100644 index 0000000..07a9ccb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_playing.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_plus.png b/app/src/main/res/drawable-xxhdpi/ott_ic_plus.png new file mode 100644 index 0000000..1d0aa81 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_plus.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_pub_play.png b/app/src/main/res/drawable-xxhdpi/ott_ic_pub_play.png new file mode 100644 index 0000000..967c1da Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_pub_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_close.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_close.webp new file mode 100644 index 0000000..1312e30 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_close.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_option_tip.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_option_tip.webp new file mode 100644 index 0000000..0163b3c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_option_tip.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_timer_divider.png b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_timer_divider.png new file mode 100644 index 0000000..f2a348c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_timer_divider.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_vip.png b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_vip.png new file mode 100644 index 0000000..f716d9a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_vip.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_vip_selected.png b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_vip_selected.png new file mode 100644 index 0000000..37634a2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_recharge_vip_selected.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_recommend_close.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_recommend_close.webp new file mode 100644 index 0000000..e1a0949 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_recommend_close.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_room_ctrl.png b/app/src/main/res/drawable-xxhdpi/ott_ic_room_ctrl.png new file mode 100644 index 0000000..42eba2d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_room_ctrl.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_rotation.png b/app/src/main/res/drawable-xxhdpi/ott_ic_rotation.png new file mode 100644 index 0000000..5a5de91 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_rotation.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_scan.png b/app/src/main/res/drawable-xxhdpi/ott_ic_scan.png new file mode 100644 index 0000000..4c2f1aa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_scan.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_score.png b/app/src/main/res/drawable-xxhdpi/ott_ic_score.png new file mode 100644 index 0000000..e11f197 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_score.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_search.png b/app/src/main/res/drawable-xxhdpi/ott_ic_search.png new file mode 100644 index 0000000..6c60155 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_search.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_setting_storage_space.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_setting_storage_space.webp new file mode 100644 index 0000000..f141523 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_setting_storage_space.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_settings.png b/app/src/main/res/drawable-xxhdpi/ott_ic_settings.png new file mode 100644 index 0000000..7ca68b5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_settings.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_settings_pwd.png b/app/src/main/res/drawable-xxhdpi/ott_ic_settings_pwd.png new file mode 100644 index 0000000..6d87a7a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_settings_pwd.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_skin_setting.png b/app/src/main/res/drawable-xxhdpi/ott_ic_skin_setting.png new file mode 100644 index 0000000..3d4da44 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_skin_setting.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_song_recommend.png b/app/src/main/res/drawable-xxhdpi/ott_ic_song_recommend.png new file mode 100644 index 0000000..44ffd69 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_song_recommend.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_sound.png b/app/src/main/res/drawable-xxhdpi/ott_ic_sound.png new file mode 100644 index 0000000..eea6fa1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_sound.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_sync.png b/app/src/main/res/drawable-xxhdpi/ott_ic_sync.png new file mode 100644 index 0000000..bc0e5d2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_sync.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_ai_help.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_ai_help.png new file mode 100644 index 0000000..10727d1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_ai_help.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_ai_help_mi.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_ai_help_mi.webp new file mode 100644 index 0000000..c80d6d1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_ai_help_mi.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_app.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_app.png new file mode 100644 index 0000000..0a5c415 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_app.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_disco.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_disco.png new file mode 100644 index 0000000..9777199 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_disco.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_language.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_language.png new file mode 100644 index 0000000..e2dfaa2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_language.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_market.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_market.png new file mode 100644 index 0000000..3f79c3b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_market.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_order_song.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_order_song.png new file mode 100644 index 0000000..e7427ac Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_order_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_recommend.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_recommend.png new file mode 100644 index 0000000..17380ed Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_recommend.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tab_setting.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_setting.png new file mode 100644 index 0000000..af90968 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tab_setting.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tip.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tip.png new file mode 100644 index 0000000..5779cab Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tip.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_top.png b/app/src/main/res/drawable-xxhdpi/ott_ic_top.png new file mode 100644 index 0000000..5d4ebb7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_top.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_tv_display_mode.png b/app/src/main/res/drawable-xxhdpi/ott_ic_tv_display_mode.png new file mode 100644 index 0000000..9705e3f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_tv_display_mode.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_add_song.png b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_add_song.png new file mode 100644 index 0000000..c8d2287 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_add_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_add_song_notice.webp b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_add_song_notice.webp new file mode 100644 index 0000000..8ccc48e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_add_song_notice.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_config.png b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_config.png new file mode 100644 index 0000000..618ff00 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_config.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_play.png b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_play.png new file mode 100644 index 0000000..cda1a7d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_udisk_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_upgrade.png b/app/src/main/res/drawable-xxhdpi/ott_ic_upgrade.png new file mode 100644 index 0000000..1f7a9ca Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_upgrade.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_video_broadcast.png b/app/src/main/res/drawable-xxhdpi/ott_ic_video_broadcast.png new file mode 100644 index 0000000..2ae1350 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_video_broadcast.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_vip_agreement.png b/app/src/main/res/drawable-xxhdpi/ott_ic_vip_agreement.png new file mode 100644 index 0000000..1d4533b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_vip_agreement.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_wifi.png b/app/src/main/res/drawable-xxhdpi/ott_ic_wifi.png new file mode 100644 index 0000000..aa49d67 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_wifi.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_wifi_free.png b/app/src/main/res/drawable-xxhdpi/ott_ic_wifi_free.png new file mode 100644 index 0000000..8027d07 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_wifi_free.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_ic_wifi_setting.png b/app/src/main/res/drawable-xxhdpi/ott_ic_wifi_setting.png new file mode 100644 index 0000000..a76d7e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_ic_wifi_setting.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_image_favorite.webp b/app/src/main/res/drawable-xxhdpi/ott_image_favorite.webp new file mode 100644 index 0000000..9465a8f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_image_favorite.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_image_favorite_selected.webp b/app/src/main/res/drawable-xxhdpi/ott_image_favorite_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_image_favorite_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_image_recent.webp b/app/src/main/res/drawable-xxhdpi/ott_image_recent.webp new file mode 100644 index 0000000..5b32760 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_image_recent.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_image_recent_selected.webp b/app/src/main/res/drawable-xxhdpi/ott_image_recent_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_image_recent_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_mask_avatar.webp b/app/src/main/res/drawable-xxhdpi/ott_mask_avatar.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_mask_avatar.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_back.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_back.png new file mode 100644 index 0000000..cf98c71 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_back.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_music_accomp.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_music_accomp.png new file mode 100644 index 0000000..37ffe54 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_music_accomp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_music_origin.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_music_origin.png new file mode 100644 index 0000000..c9373c9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_music_origin.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_orderlist.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_orderlist.png new file mode 100644 index 0000000..ca1ce64 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_orderlist.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_pause.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_pause.png new file mode 100644 index 0000000..5407649 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_pause.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_play.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_play.png new file mode 100644 index 0000000..eae6fb3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_replay.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_replay.png new file mode 100644 index 0000000..a0d7dac Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_replay.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_switch_song.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_switch_song.png new file mode 100644 index 0000000..7f83013 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_switch_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ott_tv_ic_volume.png b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_volume.png new file mode 100644 index 0000000..02f8eef Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ott_tv_ic_volume.png differ diff --git a/app/src/main/res/drawable-xxhdpi/popup_view_triangle.png b/app/src/main/res/drawable-xxhdpi/popup_view_triangle.png new file mode 100644 index 0000000..56ef5e8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/popup_view_triangle.png differ diff --git a/app/src/main/res/drawable-xxhdpi/skin_btn_check_box.png b/app/src/main/res/drawable-xxhdpi/skin_btn_check_box.png new file mode 100644 index 0000000..d35442f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/skin_btn_check_box.png differ diff --git a/app/src/main/res/drawable-xxhdpi/skin_btn_check_box_outline_blank.png b/app/src/main/res/drawable-xxhdpi/skin_btn_check_box_outline_blank.png new file mode 100644 index 0000000..38c4927 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/skin_btn_check_box_outline_blank.png differ diff --git a/app/src/main/res/drawable-xxhdpi/skin_btn_radio_checked.png b/app/src/main/res/drawable-xxhdpi/skin_btn_radio_checked.png new file mode 100644 index 0000000..363e561 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/skin_btn_radio_checked.png differ diff --git a/app/src/main/res/drawable-xxhdpi/skin_btn_radio_unchecked.png b/app/src/main/res/drawable-xxhdpi/skin_btn_radio_unchecked.png new file mode 100644 index 0000000..f1b9841 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/skin_btn_radio_unchecked.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_bg_0.webp b/app/src/main/res/drawable-xxhdpi/tpl_bg_0.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_bg_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_bg_1.webp b/app/src/main/res/drawable-xxhdpi/tpl_bg_1.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_bg_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_bg_2.webp b/app/src/main/res/drawable-xxhdpi/tpl_bg_2.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_bg_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_bg_3.webp b/app/src/main/res/drawable-xxhdpi/tpl_bg_3.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_bg_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_bg_4.webp b/app/src/main/res/drawable-xxhdpi/tpl_bg_4.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_bg_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_bg_5.webp b/app/src/main/res/drawable-xxhdpi/tpl_bg_5.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_bg_5.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_0.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_0.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_1.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_1.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_2.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_2.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_3.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_3.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_4.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_4.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_5.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_5.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_5.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_banner_0.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_0.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_banner_1.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_1.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_banner_2.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_2.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_banner_3.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_3.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_banner_4.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_4.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_banner_5.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_5.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_banner_5.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_ai_helper.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_ai_helper.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_ai_helper.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_ai_helper_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_ai_helper_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_ai_helper_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_appstore.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_appstore.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_appstore.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_appstore_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_appstore_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_appstore_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_0.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_0.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_0.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_1.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_1.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_1.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_2.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_2.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_3.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_3.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_3.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_4.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_4.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_banner_4.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_child.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_child.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_child.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_disco.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_disco.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_disco.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_drama.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_drama.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_drama.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_exit.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_exit.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_exit.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_exit_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_exit_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_exit_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_favorite.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_favorite.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_favorite.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_favorite_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_favorite_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_favorite_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_hd.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_hd.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_hd.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_old_song.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_old_song.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_old_song.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_hot.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_hot.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_hot.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_hot_2.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_hot_2.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_hot_2.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_new.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_new.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_new.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_playlist_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_rank.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_rank.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_rank.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_rank_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_rank_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_rank_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recent.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recent.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recent.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recent_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recent_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recent_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recommend.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recommend.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_recommend.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_setting.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_setting.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_setting.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_setting_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_setting_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_setting_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_singer.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_singer.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_singer.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_singer_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_singer_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_singer_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_local.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_local.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_local.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_local_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_local_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_local_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_selected.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_selected.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_song_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_tiktok.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_tiktok.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_tiktok.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_ott_video_broadcast.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_video_broadcast.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_ott_video_broadcast.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_playlist.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_new.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_new.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_new.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_new_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_new_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_new_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_playlist_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_rank.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_rank.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_rank.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_rank_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_rank_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_rank_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_singer.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_singer.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_singer.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_singer_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_singer_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_singer_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_lang.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_lang.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_lang.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_lang_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_lang_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_lang_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_style.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_style.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_style.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_style_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_style_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_style_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_type.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_drama.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_drama.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_drama.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_drama_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_drama_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_drama_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_song_type_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_special_topic.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_special_topic.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_special_topic.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_module_special_topic_pressed.webp b/app/src/main/res/drawable-xxhdpi/tpl_module_special_topic_pressed.webp new file mode 100644 index 0000000..5e2d3e0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_module_special_topic_pressed.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_more.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_more.png new file mode 100644 index 0000000..27ae84a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_more.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_more_selected.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_more_selected.png new file mode 100644 index 0000000..3603859 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_more_selected.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_movie.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_movie.png new file mode 100644 index 0000000..e309b92 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_movie.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_movie_selected.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_movie_selected.png new file mode 100644 index 0000000..e96c32e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_movie_selected.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_order_song.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_order_song.png new file mode 100644 index 0000000..adf8cac Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_order_song.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_order_song_selected.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_order_song_selected.png new file mode 100644 index 0000000..fa510a2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_order_song_selected.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_recommend.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_recommend.png new file mode 100644 index 0000000..5925daa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_recommend.png differ diff --git a/app/src/main/res/drawable-xxhdpi/tpl_tab_recommend_selected.png b/app/src/main/res/drawable-xxhdpi/tpl_tab_recommend_selected.png new file mode 100644 index 0000000..30c755e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/tpl_tab_recommend_selected.png differ diff --git a/app/src/main/res/drawable/abc_action_bar_item_background_material.xml b/app/src/main/res/drawable/abc_action_bar_item_background_material.xml new file mode 100644 index 0000000..bf1c501 --- /dev/null +++ b/app/src/main/res/drawable/abc_action_bar_item_background_material.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_btn_borderless_material.xml b/app/src/main/res/drawable/abc_btn_borderless_material.xml new file mode 100644 index 0000000..a9bff32 --- /dev/null +++ b/app/src/main/res/drawable/abc_btn_borderless_material.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_btn_check_material.xml b/app/src/main/res/drawable/abc_btn_check_material.xml new file mode 100644 index 0000000..685a0a4 --- /dev/null +++ b/app/src/main/res/drawable/abc_btn_check_material.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_btn_colored_material.xml b/app/src/main/res/drawable/abc_btn_colored_material.xml new file mode 100644 index 0000000..8d505a5 --- /dev/null +++ b/app/src/main/res/drawable/abc_btn_colored_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_btn_default_mtrl_shape.xml b/app/src/main/res/drawable/abc_btn_default_mtrl_shape.xml new file mode 100644 index 0000000..6607daa --- /dev/null +++ b/app/src/main/res/drawable/abc_btn_default_mtrl_shape.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_btn_radio_material.xml b/app/src/main/res/drawable/abc_btn_radio_material.xml new file mode 100644 index 0000000..d6dc9ff --- /dev/null +++ b/app/src/main/res/drawable/abc_btn_radio_material.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_cab_background_internal_bg.xml b/app/src/main/res/drawable/abc_cab_background_internal_bg.xml new file mode 100644 index 0000000..0bb6bcb --- /dev/null +++ b/app/src/main/res/drawable/abc_cab_background_internal_bg.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_cab_background_top_material.xml b/app/src/main/res/drawable/abc_cab_background_top_material.xml new file mode 100644 index 0000000..370888c --- /dev/null +++ b/app/src/main/res/drawable/abc_cab_background_top_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_control_background_material.xml b/app/src/main/res/drawable/abc_control_background_material.xml new file mode 100644 index 0000000..95708ec --- /dev/null +++ b/app/src/main/res/drawable/abc_control_background_material.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_dialog_material_background.xml b/app/src/main/res/drawable/abc_dialog_material_background.xml new file mode 100644 index 0000000..0714e47 --- /dev/null +++ b/app/src/main/res/drawable/abc_dialog_material_background.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_edit_text_material.xml b/app/src/main/res/drawable/abc_edit_text_material.xml new file mode 100644 index 0000000..80198ee --- /dev/null +++ b/app/src/main/res/drawable/abc_edit_text_material.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ic_ab_back_material.xml b/app/src/main/res/drawable/abc_ic_ab_back_material.xml new file mode 100644 index 0000000..ae36373 --- /dev/null +++ b/app/src/main/res/drawable/abc_ic_ab_back_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ic_arrow_drop_right_black_24dp.xml b/app/src/main/res/drawable/abc_ic_arrow_drop_right_black_24dp.xml new file mode 100644 index 0000000..77fd880 --- /dev/null +++ b/app/src/main/res/drawable/abc_ic_arrow_drop_right_black_24dp.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ic_clear_material.xml b/app/src/main/res/drawable/abc_ic_clear_material.xml new file mode 100644 index 0000000..9a5d7d7 --- /dev/null +++ b/app/src/main/res/drawable/abc_ic_clear_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ic_go_search_api_material.xml b/app/src/main/res/drawable/abc_ic_go_search_api_material.xml new file mode 100644 index 0000000..b14b59e --- /dev/null +++ b/app/src/main/res/drawable/abc_ic_go_search_api_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ic_menu_overflow_material.xml b/app/src/main/res/drawable/abc_ic_menu_overflow_material.xml new file mode 100644 index 0000000..a656d46 --- /dev/null +++ b/app/src/main/res/drawable/abc_ic_menu_overflow_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ic_search_api_material.xml b/app/src/main/res/drawable/abc_ic_search_api_material.xml new file mode 100644 index 0000000..76fb4a4 --- /dev/null +++ b/app/src/main/res/drawable/abc_ic_search_api_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ic_voice_search_api_material.xml b/app/src/main/res/drawable/abc_ic_voice_search_api_material.xml new file mode 100644 index 0000000..3f9f5a9 --- /dev/null +++ b/app/src/main/res/drawable/abc_ic_voice_search_api_material.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_item_background_holo_dark.xml b/app/src/main/res/drawable/abc_item_background_holo_dark.xml new file mode 100644 index 0000000..c648a42 --- /dev/null +++ b/app/src/main/res/drawable/abc_item_background_holo_dark.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_item_background_holo_light.xml b/app/src/main/res/drawable/abc_item_background_holo_light.xml new file mode 100644 index 0000000..979aafb --- /dev/null +++ b/app/src/main/res/drawable/abc_item_background_holo_light.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_list_divider_material.xml b/app/src/main/res/drawable/abc_list_divider_material.xml new file mode 100644 index 0000000..c05b6cd --- /dev/null +++ b/app/src/main/res/drawable/abc_list_divider_material.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_list_selector_background_transition_holo_dark.xml b/app/src/main/res/drawable/abc_list_selector_background_transition_holo_dark.xml new file mode 100644 index 0000000..ada037d --- /dev/null +++ b/app/src/main/res/drawable/abc_list_selector_background_transition_holo_dark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_list_selector_background_transition_holo_light.xml b/app/src/main/res/drawable/abc_list_selector_background_transition_holo_light.xml new file mode 100644 index 0000000..6b953eb --- /dev/null +++ b/app/src/main/res/drawable/abc_list_selector_background_transition_holo_light.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_list_selector_holo_dark.xml b/app/src/main/res/drawable/abc_list_selector_holo_dark.xml new file mode 100644 index 0000000..99a011b --- /dev/null +++ b/app/src/main/res/drawable/abc_list_selector_holo_dark.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_list_selector_holo_light.xml b/app/src/main/res/drawable/abc_list_selector_holo_light.xml new file mode 100644 index 0000000..a14f8fa --- /dev/null +++ b/app/src/main/res/drawable/abc_list_selector_holo_light.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ratingbar_indicator_material.xml b/app/src/main/res/drawable/abc_ratingbar_indicator_material.xml new file mode 100644 index 0000000..598296f --- /dev/null +++ b/app/src/main/res/drawable/abc_ratingbar_indicator_material.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ratingbar_material.xml b/app/src/main/res/drawable/abc_ratingbar_material.xml new file mode 100644 index 0000000..fb9165c --- /dev/null +++ b/app/src/main/res/drawable/abc_ratingbar_material.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_ratingbar_small_material.xml b/app/src/main/res/drawable/abc_ratingbar_small_material.xml new file mode 100644 index 0000000..edd5f3a --- /dev/null +++ b/app/src/main/res/drawable/abc_ratingbar_small_material.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_seekbar_thumb_material.xml b/app/src/main/res/drawable/abc_seekbar_thumb_material.xml new file mode 100644 index 0000000..c5ffe52 --- /dev/null +++ b/app/src/main/res/drawable/abc_seekbar_thumb_material.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_seekbar_tick_mark_material.xml b/app/src/main/res/drawable/abc_seekbar_tick_mark_material.xml new file mode 100644 index 0000000..45f699d --- /dev/null +++ b/app/src/main/res/drawable/abc_seekbar_tick_mark_material.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_seekbar_track_material.xml b/app/src/main/res/drawable/abc_seekbar_track_material.xml new file mode 100644 index 0000000..a9027dd --- /dev/null +++ b/app/src/main/res/drawable/abc_seekbar_track_material.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_spinner_textfield_background_material.xml b/app/src/main/res/drawable/abc_spinner_textfield_background_material.xml new file mode 100644 index 0000000..7888234 --- /dev/null +++ b/app/src/main/res/drawable/abc_spinner_textfield_background_material.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_switch_thumb_material.xml b/app/src/main/res/drawable/abc_switch_thumb_material.xml new file mode 100644 index 0000000..4c5bc04 --- /dev/null +++ b/app/src/main/res/drawable/abc_switch_thumb_material.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_tab_indicator_material.xml b/app/src/main/res/drawable/abc_tab_indicator_material.xml new file mode 100644 index 0000000..797f7d5 --- /dev/null +++ b/app/src/main/res/drawable/abc_tab_indicator_material.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_text_cursor_material.xml b/app/src/main/res/drawable/abc_text_cursor_material.xml new file mode 100644 index 0000000..4b9d9e9 --- /dev/null +++ b/app/src/main/res/drawable/abc_text_cursor_material.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_textfield_search_material.xml b/app/src/main/res/drawable/abc_textfield_search_material.xml new file mode 100644 index 0000000..b843709 --- /dev/null +++ b/app/src/main/res/drawable/abc_textfield_search_material.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/abc_vector_test.xml b/app/src/main/res/drawable/abc_vector_test.xml new file mode 100644 index 0000000..fd66d43 --- /dev/null +++ b/app/src/main/res/drawable/abc_vector_test.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/banner.xml b/app/src/main/res/drawable/banner.xml new file mode 100644 index 0000000..ac072d4 --- /dev/null +++ b/app/src/main/res/drawable/banner.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_age_test_touch_postion.xml b/app/src/main/res/drawable/bg_age_test_touch_postion.xml new file mode 100644 index 0000000..32ec735 --- /dev/null +++ b/app/src/main/res/drawable/bg_age_test_touch_postion.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_ai_song_item.xml b/app/src/main/res/drawable/bg_ai_song_item.xml new file mode 100644 index 0000000..a26d9de --- /dev/null +++ b/app/src/main/res/drawable/bg_ai_song_item.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_ai_song_item_index.xml b/app/src/main/res/drawable/bg_ai_song_item_index.xml new file mode 100644 index 0000000..400e924 --- /dev/null +++ b/app/src/main/res/drawable/bg_ai_song_item_index.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_appstore_list_container.xml b/app/src/main/res/drawable/bg_appstore_list_container.xml new file mode 100644 index 0000000..466be91 --- /dev/null +++ b/app/src/main/res/drawable/bg_appstore_list_container.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_appstore_list_item_download.xml b/app/src/main/res/drawable/bg_appstore_list_item_download.xml new file mode 100644 index 0000000..67c09fc --- /dev/null +++ b/app/src/main/res/drawable/bg_appstore_list_item_download.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_badge_red.xml b/app/src/main/res/drawable/bg_badge_red.xml new file mode 100644 index 0000000..9d3bf90 --- /dev/null +++ b/app/src/main/res/drawable/bg_badge_red.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_base_key.xml b/app/src/main/res/drawable/bg_base_key.xml new file mode 100644 index 0000000..e054d19 --- /dev/null +++ b/app/src/main/res/drawable/bg_base_key.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_base_key_normal.xml b/app/src/main/res/drawable/bg_base_key_normal.xml new file mode 100644 index 0000000..b5d9118 --- /dev/null +++ b/app/src/main/res/drawable/bg_base_key_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_base_key_pressed.xml b/app/src/main/res/drawable/bg_base_key_pressed.xml new file mode 100644 index 0000000..2121b05 --- /dev/null +++ b/app/src/main/res/drawable/bg_base_key_pressed.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_big_image_text.xml b/app/src/main/res/drawable/bg_big_image_text.xml new file mode 100644 index 0000000..73a9bd2 --- /dev/null +++ b/app/src/main/res/drawable/bg_big_image_text.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_big_image_text_normal.xml b/app/src/main/res/drawable/bg_big_image_text_normal.xml new file mode 100644 index 0000000..5a959c0 --- /dev/null +++ b/app/src/main/res/drawable/bg_big_image_text_normal.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_big_image_text_selected.xml b/app/src/main/res/drawable/bg_big_image_text_selected.xml new file mode 100644 index 0000000..1f1c39e --- /dev/null +++ b/app/src/main/res/drawable/bg_big_image_text_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_bottom_bar.xml b/app/src/main/res/drawable/bg_bottom_bar.xml new file mode 100644 index 0000000..3db282a --- /dev/null +++ b/app/src/main/res/drawable/bg_bottom_bar.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_bottom_navigation.xml b/app/src/main/res/drawable/bg_bottom_navigation.xml new file mode 100644 index 0000000..10dd4ba --- /dev/null +++ b/app/src/main/res/drawable/bg_bottom_navigation.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_back.xml b/app/src/main/res/drawable/bg_btn_back.xml new file mode 100644 index 0000000..92e04e0 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_back.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_dialog_close.xml b/app/src/main/res/drawable/bg_btn_dialog_close.xml new file mode 100644 index 0000000..0c7dc79 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_dialog_close.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_glass.xml b/app/src/main/res/drawable/bg_btn_glass.xml new file mode 100644 index 0000000..3fb6724 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_glass.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_guess.xml b/app/src/main/res/drawable/bg_btn_guess.xml new file mode 100644 index 0000000..51f0fad --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_guess.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_pager.xml b/app/src/main/res/drawable/bg_btn_pager.xml new file mode 100644 index 0000000..0ed8b14 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_pager.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_save.xml b/app/src/main/res/drawable/bg_btn_save.xml new file mode 100644 index 0000000..5c744b0 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_save.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_search.xml b/app/src/main/res/drawable/bg_btn_search.xml new file mode 100644 index 0000000..5133f4d --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_search.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_toggle_off.xml b/app/src/main/res/drawable/bg_btn_toggle_off.xml new file mode 100644 index 0000000..b5f1fd1 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_toggle_off.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_toggle_on.xml b/app/src/main/res/drawable/bg_btn_toggle_on.xml new file mode 100644 index 0000000..0426f97 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_toggle_on.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_top_action.xml b/app/src/main/res/drawable/bg_btn_top_action.xml new file mode 100644 index 0000000..e6d46e3 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_top_action.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_top_order.xml b/app/src/main/res/drawable/bg_btn_top_order.xml new file mode 100644 index 0000000..c9e3c60 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_top_order.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_top_search.xml b/app/src/main/res/drawable/bg_btn_top_search.xml new file mode 100644 index 0000000..93c5f43 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_top_search.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_voice.xml b/app/src/main/res/drawable/bg_btn_voice.xml new file mode 100644 index 0000000..21995ba --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_voice.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_button.xml b/app/src/main/res/drawable/bg_button.xml new file mode 100644 index 0000000..4d15ad7 --- /dev/null +++ b/app/src/main/res/drawable/bg_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_button_jump_over.xml b/app/src/main/res/drawable/bg_button_jump_over.xml new file mode 100644 index 0000000..ceb0ab1 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_jump_over.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_button_negative.xml b/app/src/main/res/drawable/bg_button_negative.xml new file mode 100644 index 0000000..c56e456 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_negative.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_button_quick_search.xml b/app/src/main/res/drawable/bg_button_quick_search.xml new file mode 100644 index 0000000..18b1083 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_quick_search.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_button_song_title.xml b/app/src/main/res/drawable/bg_button_song_title.xml new file mode 100644 index 0000000..720c816 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_song_title.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_button_using.xml b/app/src/main/res/drawable/bg_button_using.xml new file mode 100644 index 0000000..9191aa1 --- /dev/null +++ b/app/src/main/res/drawable/bg_button_using.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_call_type_list_item.xml b/app/src/main/res/drawable/bg_call_type_list_item.xml new file mode 100644 index 0000000..1355df8 --- /dev/null +++ b/app/src/main/res/drawable/bg_call_type_list_item.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_candidate_list.xml b/app/src/main/res/drawable/bg_candidate_list.xml new file mode 100644 index 0000000..8d55bfd --- /dev/null +++ b/app/src/main/res/drawable/bg_candidate_list.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_content_blue_10.xml b/app/src/main/res/drawable/bg_content_blue_10.xml new file mode 100644 index 0000000..2475372 --- /dev/null +++ b/app/src/main/res/drawable/bg_content_blue_10.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_content_blue_20.xml b/app/src/main/res/drawable/bg_content_blue_20.xml new file mode 100644 index 0000000..40c3194 --- /dev/null +++ b/app/src/main/res/drawable/bg_content_blue_20.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_content_share_qrcode.xml b/app/src/main/res/drawable/bg_content_share_qrcode.xml new file mode 100644 index 0000000..cd68c40 --- /dev/null +++ b/app/src/main/res/drawable/bg_content_share_qrcode.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_content_song_title.xml b/app/src/main/res/drawable/bg_content_song_title.xml new file mode 100644 index 0000000..c3d0f12 --- /dev/null +++ b/app/src/main/res/drawable/bg_content_song_title.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_content_transparent_30.xml b/app/src/main/res/drawable/bg_content_transparent_30.xml new file mode 100644 index 0000000..529048a --- /dev/null +++ b/app/src/main/res/drawable/bg_content_transparent_30.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_content_view_number_change.xml b/app/src/main/res/drawable/bg_content_view_number_change.xml new file mode 100644 index 0000000..cd68c40 --- /dev/null +++ b/app/src/main/res/drawable/bg_content_view_number_change.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_dialog_90.xml b/app/src/main/res/drawable/bg_dialog_90.xml new file mode 100644 index 0000000..feddf5f --- /dev/null +++ b/app/src/main/res/drawable/bg_dialog_90.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_dialog_panel.xml b/app/src/main/res/drawable/bg_dialog_panel.xml new file mode 100644 index 0000000..24231e5 --- /dev/null +++ b/app/src/main/res/drawable/bg_dialog_panel.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_dialog_room_service.xml b/app/src/main/res/drawable/bg_dialog_room_service.xml new file mode 100644 index 0000000..32c2726 --- /dev/null +++ b/app/src/main/res/drawable/bg_dialog_room_service.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_divider.xml b/app/src/main/res/drawable/bg_divider.xml new file mode 100644 index 0000000..962c594 --- /dev/null +++ b/app/src/main/res/drawable/bg_divider.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_divider_gray.xml b/app/src/main/res/drawable/bg_divider_gray.xml new file mode 100644 index 0000000..e704ae3 --- /dev/null +++ b/app/src/main/res/drawable/bg_divider_gray.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_divider_wifi_list.xml b/app/src/main/res/drawable/bg_divider_wifi_list.xml new file mode 100644 index 0000000..8a6a333 --- /dev/null +++ b/app/src/main/res/drawable/bg_divider_wifi_list.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_download_progress.xml b/app/src/main/res/drawable/bg_download_progress.xml new file mode 100644 index 0000000..6adf182 --- /dev/null +++ b/app/src/main/res/drawable/bg_download_progress.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_handwrite.xml b/app/src/main/res/drawable/bg_handwrite.xml new file mode 100644 index 0000000..d7d0165 --- /dev/null +++ b/app/src/main/res/drawable/bg_handwrite.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_home_card_blue.xml b/app/src/main/res/drawable/bg_home_card_blue.xml new file mode 100644 index 0000000..7b93523 --- /dev/null +++ b/app/src/main/res/drawable/bg_home_card_blue.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_home_card_orange.xml b/app/src/main/res/drawable/bg_home_card_orange.xml new file mode 100644 index 0000000..b0f280f --- /dev/null +++ b/app/src/main/res/drawable/bg_home_card_orange.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_home_card_pink.xml b/app/src/main/res/drawable/bg_home_card_pink.xml new file mode 100644 index 0000000..316d86c --- /dev/null +++ b/app/src/main/res/drawable/bg_home_card_pink.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_home_card_purple.xml b/app/src/main/res/drawable/bg_home_card_purple.xml new file mode 100644 index 0000000..c09acfb --- /dev/null +++ b/app/src/main/res/drawable/bg_home_card_purple.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_home_poster.xml b/app/src/main/res/drawable/bg_home_poster.xml new file mode 100644 index 0000000..c2b9ef2 --- /dev/null +++ b/app/src/main/res/drawable/bg_home_poster.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_hot_switch_a_z.xml b/app/src/main/res/drawable/bg_hot_switch_a_z.xml new file mode 100644 index 0000000..a577005 --- /dev/null +++ b/app/src/main/res/drawable/bg_hot_switch_a_z.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_hot_switch_a_z_not_selected.xml b/app/src/main/res/drawable/bg_hot_switch_a_z_not_selected.xml new file mode 100644 index 0000000..4b28f82 --- /dev/null +++ b/app/src/main/res/drawable/bg_hot_switch_a_z_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_hot_switch_a_z_selected.xml b/app/src/main/res/drawable/bg_hot_switch_a_z_selected.xml new file mode 100644 index 0000000..2f83941 --- /dev/null +++ b/app/src/main/res/drawable/bg_hot_switch_a_z_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_hot_switch_hot.xml b/app/src/main/res/drawable/bg_hot_switch_hot.xml new file mode 100644 index 0000000..4fd4ee6 --- /dev/null +++ b/app/src/main/res/drawable/bg_hot_switch_hot.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_hot_switch_hot_not_selected.xml b/app/src/main/res/drawable/bg_hot_switch_hot_not_selected.xml new file mode 100644 index 0000000..d3dea91 --- /dev/null +++ b/app/src/main/res/drawable/bg_hot_switch_hot_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_hot_switch_hot_selected.xml b/app/src/main/res/drawable/bg_hot_switch_hot_selected.xml new file mode 100644 index 0000000..1aab21c --- /dev/null +++ b/app/src/main/res/drawable/bg_hot_switch_hot_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_input_text.xml b/app/src/main/res/drawable/bg_input_text.xml new file mode 100644 index 0000000..f391220 --- /dev/null +++ b/app/src/main/res/drawable/bg_input_text.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_input_text_not_selected.xml b/app/src/main/res/drawable/bg_input_text_not_selected.xml new file mode 100644 index 0000000..dc4d2b7 --- /dev/null +++ b/app/src/main/res/drawable/bg_input_text_not_selected.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_input_text_selected.xml b/app/src/main/res/drawable/bg_input_text_selected.xml new file mode 100644 index 0000000..828b081 --- /dev/null +++ b/app/src/main/res/drawable/bg_input_text_selected.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_kb_clear.xml b/app/src/main/res/drawable/bg_kb_clear.xml new file mode 100644 index 0000000..1e31b48 --- /dev/null +++ b/app/src/main/res/drawable/bg_kb_clear.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_kb_tab_item_normal.xml b/app/src/main/res/drawable/bg_kb_tab_item_normal.xml new file mode 100644 index 0000000..2773e7c --- /dev/null +++ b/app/src/main/res/drawable/bg_kb_tab_item_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_kb_tab_item_pressed.xml b/app/src/main/res/drawable/bg_kb_tab_item_pressed.xml new file mode 100644 index 0000000..4576251 --- /dev/null +++ b/app/src/main/res/drawable/bg_kb_tab_item_pressed.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_key_border_all.xml b/app/src/main/res/drawable/bg_key_border_all.xml new file mode 100644 index 0000000..cb511f3 --- /dev/null +++ b/app/src/main/res/drawable/bg_key_border_all.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_key_border_all_normal.xml b/app/src/main/res/drawable/bg_key_border_all_normal.xml new file mode 100644 index 0000000..f1800b7 --- /dev/null +++ b/app/src/main/res/drawable/bg_key_border_all_normal.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_key_char.xml b/app/src/main/res/drawable/bg_key_char.xml new file mode 100644 index 0000000..49ef251 --- /dev/null +++ b/app/src/main/res/drawable/bg_key_char.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_key_char_pressed.xml b/app/src/main/res/drawable/bg_key_char_pressed.xml new file mode 100644 index 0000000..571bfe5 --- /dev/null +++ b/app/src/main/res/drawable/bg_key_char_pressed.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_key_func.xml b/app/src/main/res/drawable/bg_key_func.xml new file mode 100644 index 0000000..95f46d1 --- /dev/null +++ b/app/src/main/res/drawable/bg_key_func.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_key_mode.xml b/app/src/main/res/drawable/bg_key_mode.xml new file mode 100644 index 0000000..63c7609 --- /dev/null +++ b/app/src/main/res/drawable/bg_key_mode.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_key_mode_dim.xml b/app/src/main/res/drawable/bg_key_mode_dim.xml new file mode 100644 index 0000000..3954d1b --- /dev/null +++ b/app/src/main/res/drawable/bg_key_mode_dim.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_keyboard_area.xml b/app/src/main/res/drawable/bg_keyboard_area.xml new file mode 100644 index 0000000..8b4d3b1 --- /dev/null +++ b/app/src/main/res/drawable/bg_keyboard_area.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_layout_content.xml b/app/src/main/res/drawable/bg_layout_content.xml new file mode 100644 index 0000000..f4028db --- /dev/null +++ b/app/src/main/res/drawable/bg_layout_content.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_left_header.xml b/app/src/main/res/drawable/bg_left_header.xml new file mode 100644 index 0000000..5b151b9 --- /dev/null +++ b/app/src/main/res/drawable/bg_left_header.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_logo_clip.xml b/app/src/main/res/drawable/bg_logo_clip.xml new file mode 100644 index 0000000..27b9573 --- /dev/null +++ b/app/src/main/res/drawable/bg_logo_clip.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_made_cmd_item.xml b/app/src/main/res/drawable/bg_made_cmd_item.xml new file mode 100644 index 0000000..4395ac7 --- /dev/null +++ b/app/src/main/res/drawable/bg_made_cmd_item.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_made_cmd_item_selected.xml b/app/src/main/res/drawable/bg_made_cmd_item_selected.xml new file mode 100644 index 0000000..44b86bb --- /dev/null +++ b/app/src/main/res/drawable/bg_made_cmd_item_selected.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_made_cmd_tab.xml b/app/src/main/res/drawable/bg_made_cmd_tab.xml new file mode 100644 index 0000000..56e0ab4 --- /dev/null +++ b/app/src/main/res/drawable/bg_made_cmd_tab.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_main_purple.xml b/app/src/main/res/drawable/bg_main_purple.xml new file mode 100644 index 0000000..3fd95f5 --- /dev/null +++ b/app/src/main/res/drawable/bg_main_purple.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_music_lyric.xml b/app/src/main/res/drawable/bg_music_lyric.xml new file mode 100644 index 0000000..6458e77 --- /dev/null +++ b/app/src/main/res/drawable/bg_music_lyric.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_order_count.xml b/app/src/main/res/drawable/bg_order_count.xml new file mode 100644 index 0000000..a0129f0 --- /dev/null +++ b/app/src/main/res/drawable/bg_order_count.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_page_button.xml b/app/src/main/res/drawable/bg_page_button.xml new file mode 100644 index 0000000..a17b0ef --- /dev/null +++ b/app/src/main/res/drawable/bg_page_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_player_focus.xml b/app/src/main/res/drawable/bg_player_focus.xml new file mode 100644 index 0000000..a1b9cf3 --- /dev/null +++ b/app/src/main/res/drawable/bg_player_focus.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_player_window.xml b/app/src/main/res/drawable/bg_player_window.xml new file mode 100644 index 0000000..a401124 --- /dev/null +++ b/app/src/main/res/drawable/bg_player_window.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_playlist_avatar.xml b/app/src/main/res/drawable/bg_playlist_avatar.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/bg_playlist_avatar.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_popup_window.xml b/app/src/main/res/drawable/bg_popup_window.xml new file mode 100644 index 0000000..561cfaa --- /dev/null +++ b/app/src/main/res/drawable/bg_popup_window.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_progressbar.xml b/app/src/main/res/drawable/bg_progressbar.xml new file mode 100644 index 0000000..3becfca --- /dev/null +++ b/app/src/main/res/drawable/bg_progressbar.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_qrcode.xml b/app/src/main/res/drawable/bg_qrcode.xml new file mode 100644 index 0000000..35ac51a --- /dev/null +++ b/app/src/main/res/drawable/bg_qrcode.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_quick_search_singer_avatar.xml b/app/src/main/res/drawable/bg_quick_search_singer_avatar.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/bg_quick_search_singer_avatar.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_random_button.xml b/app/src/main/res/drawable/bg_random_button.xml new file mode 100644 index 0000000..fc27b56 --- /dev/null +++ b/app/src/main/res/drawable/bg_random_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_recommend_confirm.xml b/app/src/main/res/drawable/bg_recommend_confirm.xml new file mode 100644 index 0000000..eb5f8b0 --- /dev/null +++ b/app/src/main/res/drawable/bg_recommend_confirm.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_recommend_confirm_focused.xml b/app/src/main/res/drawable/bg_recommend_confirm_focused.xml new file mode 100644 index 0000000..48e777c --- /dev/null +++ b/app/src/main/res/drawable/bg_recommend_confirm_focused.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_score_champion_mark.xml b/app/src/main/res/drawable/bg_score_champion_mark.xml new file mode 100644 index 0000000..a9a20cf --- /dev/null +++ b/app/src/main/res/drawable/bg_score_champion_mark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_score_combo_count_1.xml b/app/src/main/res/drawable/bg_score_combo_count_1.xml new file mode 100644 index 0000000..cbb0438 --- /dev/null +++ b/app/src/main/res/drawable/bg_score_combo_count_1.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_score_combo_count_2.xml b/app/src/main/res/drawable/bg_score_combo_count_2.xml new file mode 100644 index 0000000..40d27b8 --- /dev/null +++ b/app/src/main/res/drawable/bg_score_combo_count_2.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_score_combo_count_3.xml b/app/src/main/res/drawable/bg_score_combo_count_3.xml new file mode 100644 index 0000000..f1d1e61 --- /dev/null +++ b/app/src/main/res/drawable/bg_score_combo_count_3.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_score_level_round.xml b/app/src/main/res/drawable/bg_score_level_round.xml new file mode 100644 index 0000000..d13cbc6 --- /dev/null +++ b/app/src/main/res/drawable/bg_score_level_round.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_score_result_mini.xml b/app/src/main/res/drawable/bg_score_result_mini.xml new file mode 100644 index 0000000..42258cc --- /dev/null +++ b/app/src/main/res/drawable/bg_score_result_mini.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_score_user_avatar.xml b/app/src/main/res/drawable/bg_score_user_avatar.xml new file mode 100644 index 0000000..f350583 --- /dev/null +++ b/app/src/main/res/drawable/bg_score_user_avatar.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_score_view.xml b/app/src/main/res/drawable/bg_score_view.xml new file mode 100644 index 0000000..c211da1 --- /dev/null +++ b/app/src/main/res/drawable/bg_score_view.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_search_edittext.xml b/app/src/main/res/drawable/bg_search_edittext.xml new file mode 100644 index 0000000..e20cd52 --- /dev/null +++ b/app/src/main/res/drawable/bg_search_edittext.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_search_edittext_round.xml b/app/src/main/res/drawable/bg_search_edittext_round.xml new file mode 100644 index 0000000..7336448 --- /dev/null +++ b/app/src/main/res/drawable/bg_search_edittext_round.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_seekbar_progress.xml b/app/src/main/res/drawable/bg_seekbar_progress.xml new file mode 100644 index 0000000..6f50c48 --- /dev/null +++ b/app/src/main/res/drawable/bg_seekbar_progress.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_seekbar_thumb.xml b/app/src/main/res/drawable/bg_seekbar_thumb.xml new file mode 100644 index 0000000..0811b9b --- /dev/null +++ b/app/src/main/res/drawable/bg_seekbar_thumb.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_seekbar_thumb_mini.xml b/app/src/main/res/drawable/bg_seekbar_thumb_mini.xml new file mode 100644 index 0000000..fbc2886 --- /dev/null +++ b/app/src/main/res/drawable/bg_seekbar_thumb_mini.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_seekbar_thumb_normal.xml b/app/src/main/res/drawable/bg_seekbar_thumb_normal.xml new file mode 100644 index 0000000..e909398 --- /dev/null +++ b/app/src/main/res/drawable/bg_seekbar_thumb_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_seekbar_thumb_normal_mini.xml b/app/src/main/res/drawable/bg_seekbar_thumb_normal_mini.xml new file mode 100644 index 0000000..7454e5c --- /dev/null +++ b/app/src/main/res/drawable/bg_seekbar_thumb_normal_mini.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_seekbar_thumb_pressed.xml b/app/src/main/res/drawable/bg_seekbar_thumb_pressed.xml new file mode 100644 index 0000000..5898d76 --- /dev/null +++ b/app/src/main/res/drawable/bg_seekbar_thumb_pressed.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_settings_item.xml b/app/src/main/res/drawable/bg_settings_item.xml new file mode 100644 index 0000000..83bb082 --- /dev/null +++ b/app/src/main/res/drawable/bg_settings_item.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_settings_item_selected.xml b/app/src/main/res/drawable/bg_settings_item_selected.xml new file mode 100644 index 0000000..d18d9a3 --- /dev/null +++ b/app/src/main/res/drawable/bg_settings_item_selected.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_sing_guide_view.xml b/app/src/main/res/drawable/bg_sing_guide_view.xml new file mode 100644 index 0000000..08b5312 --- /dev/null +++ b/app/src/main/res/drawable/bg_sing_guide_view.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_singer_list_avatar.xml b/app/src/main/res/drawable/bg_singer_list_avatar.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/bg_singer_list_avatar.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_song_action_focus.xml b/app/src/main/res/drawable/bg_song_action_focus.xml new file mode 100644 index 0000000..10dce6d --- /dev/null +++ b/app/src/main/res/drawable/bg_song_action_focus.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_song_item.xml b/app/src/main/res/drawable/bg_song_item.xml new file mode 100644 index 0000000..5b7f879 --- /dev/null +++ b/app/src/main/res/drawable/bg_song_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_song_list.xml b/app/src/main/res/drawable/bg_song_list.xml new file mode 100644 index 0000000..2678431 --- /dev/null +++ b/app/src/main/res/drawable/bg_song_list.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_switch_button.xml b/app/src/main/res/drawable/bg_switch_button.xml new file mode 100644 index 0000000..d9d9ea2 --- /dev/null +++ b/app/src/main/res/drawable/bg_switch_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_switch_button_off.xml b/app/src/main/res/drawable/bg_switch_button_off.xml new file mode 100644 index 0000000..0ddbe98 --- /dev/null +++ b/app/src/main/res/drawable/bg_switch_button_off.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_switch_button_on.xml b/app/src/main/res/drawable/bg_switch_button_on.xml new file mode 100644 index 0000000..79ea654 --- /dev/null +++ b/app/src/main/res/drawable/bg_switch_button_on.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_tag_button.xml b/app/src/main/res/drawable/bg_tag_button.xml new file mode 100644 index 0000000..d27931b --- /dev/null +++ b/app/src/main/res/drawable/bg_tag_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_tag_button_selected.xml b/app/src/main/res/drawable/bg_tag_button_selected.xml new file mode 100644 index 0000000..d89b956 --- /dev/null +++ b/app/src/main/res/drawable/bg_tag_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_text_tab_item.xml b/app/src/main/res/drawable/bg_text_tab_item.xml new file mode 100644 index 0000000..9cad6ef --- /dev/null +++ b/app/src/main/res/drawable/bg_text_tab_item.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_text_tab_item_border_default.xml b/app/src/main/res/drawable/bg_text_tab_item_border_default.xml new file mode 100644 index 0000000..1818fa8 --- /dev/null +++ b/app/src/main/res/drawable/bg_text_tab_item_border_default.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_text_tab_item_border_orderlist.xml b/app/src/main/res/drawable/bg_text_tab_item_border_orderlist.xml new file mode 100644 index 0000000..962c594 --- /dev/null +++ b/app/src/main/res/drawable/bg_text_tab_item_border_orderlist.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_text_tab_switch.xml b/app/src/main/res/drawable/bg_text_tab_switch.xml new file mode 100644 index 0000000..2005459 --- /dev/null +++ b/app/src/main/res/drawable/bg_text_tab_switch.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_tone_number.xml b/app/src/main/res/drawable/bg_tone_number.xml new file mode 100644 index 0000000..e0b2023 --- /dev/null +++ b/app/src/main/res/drawable/bg_tone_number.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_top_bar.xml b/app/src/main/res/drawable/bg_top_bar.xml new file mode 100644 index 0000000..6c9332a --- /dev/null +++ b/app/src/main/res/drawable/bg_top_bar.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_top_navigation.xml b/app/src/main/res/drawable/bg_top_navigation.xml new file mode 100644 index 0000000..9ac68f6 --- /dev/null +++ b/app/src/main/res/drawable/bg_top_navigation.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/bg_tuning_counter.xml b/app/src/main/res/drawable/bg_tuning_counter.xml new file mode 100644 index 0000000..56861b9 --- /dev/null +++ b/app/src/main/res/drawable/bg_tuning_counter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_tuning_dialog.xml b/app/src/main/res/drawable/bg_tuning_dialog.xml new file mode 100644 index 0000000..592a141 --- /dev/null +++ b/app/src/main/res/drawable/bg_tuning_dialog.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_tv_dialog_choice_focus.xml b/app/src/main/res/drawable/bg_tv_dialog_choice_focus.xml new file mode 100644 index 0000000..d981147 --- /dev/null +++ b/app/src/main/res/drawable/bg_tv_dialog_choice_focus.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_tv_layout_song_title_qrcode.xml b/app/src/main/res/drawable/bg_tv_layout_song_title_qrcode.xml new file mode 100644 index 0000000..73ecf7f --- /dev/null +++ b/app/src/main/res/drawable/bg_tv_layout_song_title_qrcode.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_tv_play_ctrl.xml b/app/src/main/res/drawable/bg_tv_play_ctrl.xml new file mode 100644 index 0000000..c7741ec --- /dev/null +++ b/app/src/main/res/drawable/bg_tv_play_ctrl.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_video_main_layout.xml b/app/src/main/res/drawable/bg_video_main_layout.xml new file mode 100644 index 0000000..2d40d21 --- /dev/null +++ b/app/src/main/res/drawable/bg_video_main_layout.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_window.xml b/app/src/main/res/drawable/bg_window.xml new file mode 100644 index 0000000..41b1281 --- /dev/null +++ b/app/src/main/res/drawable/bg_window.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_word.xml b/app/src/main/res/drawable/bg_word.xml new file mode 100644 index 0000000..9db6f87 --- /dev/null +++ b/app/src/main/res/drawable/bg_word.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_word_limit_normal.xml b/app/src/main/res/drawable/bg_word_limit_normal.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/bg_word_limit_normal.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_word_limit_selected.xml b/app/src/main/res/drawable/bg_word_limit_selected.xml new file mode 100644 index 0000000..f1ef00d --- /dev/null +++ b/app/src/main/res/drawable/bg_word_limit_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_word_normal.xml b/app/src/main/res/drawable/bg_word_normal.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/bg_word_normal.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_word_pressed.xml b/app/src/main/res/drawable/bg_word_pressed.xml new file mode 100644 index 0000000..a8da348 --- /dev/null +++ b/app/src/main/res/drawable/bg_word_pressed.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/black_background.xml b/app/src/main/res/drawable/black_background.xml new file mode 100644 index 0000000..79e70ab --- /dev/null +++ b/app/src/main/res/drawable/black_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/border_search_edittext.xml b/app/src/main/res/drawable/border_search_edittext.xml new file mode 100644 index 0000000..e6b5d0b --- /dev/null +++ b/app/src/main/res/drawable/border_search_edittext.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/border_search_edittext_all.xml b/app/src/main/res/drawable/border_search_edittext_all.xml new file mode 100644 index 0000000..4f2ffb8 --- /dev/null +++ b/app/src/main/res/drawable/border_search_edittext_all.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/divider_dot_line.xml b/app/src/main/res/drawable/divider_dot_line.xml new file mode 100644 index 0000000..769940e --- /dev/null +++ b/app/src/main/res/drawable/divider_dot_line.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/divider_dot_line_vertical.xml b/app/src/main/res/drawable/divider_dot_line_vertical.xml new file mode 100644 index 0000000..d96923b --- /dev/null +++ b/app/src/main/res/drawable/divider_dot_line_vertical.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/divider_song_list_item.xml b/app/src/main/res/drawable/divider_song_list_item.xml new file mode 100644 index 0000000..0f7af01 --- /dev/null +++ b/app/src/main/res/drawable/divider_song_list_item.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dkplayer_layer_progress_bar.xml b/app/src/main/res/drawable/dkplayer_layer_progress_bar.xml new file mode 100644 index 0000000..7fb0acf --- /dev/null +++ b/app/src/main/res/drawable/dkplayer_layer_progress_bar.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dkplayer_seekbar_thumb.xml b/app/src/main/res/drawable/dkplayer_seekbar_thumb.xml new file mode 100644 index 0000000..a62be40 --- /dev/null +++ b/app/src/main/res/drawable/dkplayer_seekbar_thumb.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dkplayer_seekbar_thumb_normal.xml b/app/src/main/res/drawable/dkplayer_seekbar_thumb_normal.xml new file mode 100644 index 0000000..f569fb3 --- /dev/null +++ b/app/src/main/res/drawable/dkplayer_seekbar_thumb_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dkplayer_seekbar_thumb_pressed.xml b/app/src/main/res/drawable/dkplayer_seekbar_thumb_pressed.xml new file mode 100644 index 0000000..f569fb3 --- /dev/null +++ b/app/src/main/res/drawable/dkplayer_seekbar_thumb_pressed.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gray_radius.xml b/app/src/main/res/drawable/gray_radius.xml new file mode 100644 index 0000000..ded6854 --- /dev/null +++ b/app/src/main/res/drawable/gray_radius.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/home_menu_tab_text_color.xml b/app/src/main/res/drawable/home_menu_tab_text_color.xml new file mode 100644 index 0000000..757d276 --- /dev/null +++ b/app/src/main/res/drawable/home_menu_tab_text_color.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..a8c6357 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_next.xml b/app/src/main/res/drawable/ic_arrow_next.xml new file mode 100644 index 0000000..11f24ad --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_next.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_pre.xml b/app/src/main/res/drawable/ic_arrow_pre.xml new file mode 100644 index 0000000..0d53e01 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_pre.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery.xml b/app/src/main/res/drawable/ic_battery.xml new file mode 100644 index 0000000..7cfb633 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery_charge_four.xml b/app/src/main/res/drawable/ic_battery_charge_four.xml new file mode 100644 index 0000000..2212fec --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_charge_four.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_charge_low.xml b/app/src/main/res/drawable/ic_battery_charge_low.xml new file mode 100644 index 0000000..7d9398d --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_charge_low.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_charge_one.xml b/app/src/main/res/drawable/ic_battery_charge_one.xml new file mode 100644 index 0000000..ed68f9d --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_charge_one.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_charge_three.xml b/app/src/main/res/drawable/ic_battery_charge_three.xml new file mode 100644 index 0000000..1b20932 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_charge_three.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_charge_two.xml b/app/src/main/res/drawable/ic_battery_charge_two.xml new file mode 100644 index 0000000..eaf1e46 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_charge_two.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_four.xml b/app/src/main/res/drawable/ic_battery_four.xml new file mode 100644 index 0000000..02b60d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_four.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_low.xml b/app/src/main/res/drawable/ic_battery_low.xml new file mode 100644 index 0000000..d2614a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_low.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_one.xml b/app/src/main/res/drawable/ic_battery_one.xml new file mode 100644 index 0000000..5cc2286 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_one.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_three.xml b/app/src/main/res/drawable/ic_battery_three.xml new file mode 100644 index 0000000..c71718a --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_three.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery_two.xml b/app/src/main/res/drawable/ic_battery_two.xml new file mode 100644 index 0000000..c60c3f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_two.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_call_room_service.xml b/app/src/main/res/drawable/ic_call_room_service.xml new file mode 100644 index 0000000..3700ad1 --- /dev/null +++ b/app/src/main/res/drawable/ic_call_room_service.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cancel_room_service.xml b/app/src/main/res/drawable/ic_cancel_room_service.xml new file mode 100644 index 0000000..d45feef --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel_room_service.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..4ec8e4d --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_atmosphere.xml b/app/src/main/res/drawable/ic_ctrl_atmosphere.xml new file mode 100644 index 0000000..f4830a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_atmosphere.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_mobile.xml b/app/src/main/res/drawable/ic_ctrl_mobile.xml new file mode 100644 index 0000000..071edc3 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_mobile.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_music_accomp.xml b/app/src/main/res/drawable/ic_ctrl_music_accomp.xml new file mode 100644 index 0000000..c6f8d44 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_music_accomp.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_music_origin.xml b/app/src/main/res/drawable/ic_ctrl_music_origin.xml new file mode 100644 index 0000000..fab5a3f --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_music_origin.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_orderlist.xml b/app/src/main/res/drawable/ic_ctrl_orderlist.xml new file mode 100644 index 0000000..819a2f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_orderlist.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_pause.xml b/app/src/main/res/drawable/ic_ctrl_pause.xml new file mode 100644 index 0000000..1c2c119 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_pause.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_phantom.xml b/app/src/main/res/drawable/ic_ctrl_phantom.xml new file mode 100644 index 0000000..620623f --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_phantom.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_play.xml b/app/src/main/res/drawable/ic_ctrl_play.xml new file mode 100644 index 0000000..46144a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_play.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_replay.xml b/app/src/main/res/drawable/ic_ctrl_replay.xml new file mode 100644 index 0000000..e023a46 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_replay.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_sound_disable.xml b/app/src/main/res/drawable/ic_ctrl_sound_disable.xml new file mode 100644 index 0000000..167f369 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_sound_disable.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_sound_enable.xml b/app/src/main/res/drawable/ic_ctrl_sound_enable.xml new file mode 100644 index 0000000..657027c --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_sound_enable.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_switch_song.xml b/app/src/main/res/drawable/ic_ctrl_switch_song.xml new file mode 100644 index 0000000..bdc9609 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_switch_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tone.xml b/app/src/main/res/drawable/ic_ctrl_tone.xml new file mode 100644 index 0000000..daba445 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tone.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_accomp.xml b/app/src/main/res/drawable/ic_ctrl_tv_accomp.xml new file mode 100644 index 0000000..cafa9c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_accomp.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_auto_light_off.xml b/app/src/main/res/drawable/ic_ctrl_tv_auto_light_off.xml new file mode 100644 index 0000000..f48d566 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_auto_light_off.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_auto_light_on.xml b/app/src/main/res/drawable/ic_ctrl_tv_auto_light_on.xml new file mode 100644 index 0000000..9a244fd --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_auto_light_on.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_close_sound.xml b/app/src/main/res/drawable/ic_ctrl_tv_close_sound.xml new file mode 100644 index 0000000..8c49207 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_close_sound.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_blue.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_blue.xml new file mode 100644 index 0000000..06c093b --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_blue.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_classic.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_classic.xml new file mode 100644 index 0000000..fd32402 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_classic.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_country.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_country.xml new file mode 100644 index 0000000..1399f13 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_country.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_electric.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_electric.xml new file mode 100644 index 0000000..7711e80 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_electric.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_human.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_human.xml new file mode 100644 index 0000000..5f0d4b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_human.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_jazz.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_jazz.xml new file mode 100644 index 0000000..6a966bc --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_jazz.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_rock.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_rock.xml new file mode 100644 index 0000000..bba0db7 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_rock.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_eq_slow.xml b/app/src/main/res/drawable/ic_ctrl_tv_eq_slow.xml new file mode 100644 index 0000000..8410838 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_eq_slow.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_1.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_1.xml new file mode 100644 index 0000000..8d3a2bc --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_1.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_2.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_2.xml new file mode 100644 index 0000000..ffa73f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_2.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_3.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_3.xml new file mode 100644 index 0000000..097b756 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_3.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_4.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_4.xml new file mode 100644 index 0000000..d56777e --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_4.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_5.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_5.xml new file mode 100644 index 0000000..f5f60f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_5.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_6.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_6.xml new file mode 100644 index 0000000..e2c6f76 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_6.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_7.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_7.xml new file mode 100644 index 0000000..4039ef5 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_7.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_light_8.xml b/app/src/main/res/drawable/ic_ctrl_tv_light_8.xml new file mode 100644 index 0000000..3aa12e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_light_8.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_mic_decr.xml b/app/src/main/res/drawable/ic_ctrl_tv_mic_decr.xml new file mode 100644 index 0000000..3b8826f --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_mic_decr.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_mic_incr.xml b/app/src/main/res/drawable/ic_ctrl_tv_mic_incr.xml new file mode 100644 index 0000000..971e0e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_mic_incr.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_mixed_decr.xml b/app/src/main/res/drawable/ic_ctrl_tv_mixed_decr.xml new file mode 100644 index 0000000..e042618 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_mixed_decr.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_mixed_incr.xml b/app/src/main/res/drawable/ic_ctrl_tv_mixed_incr.xml new file mode 100644 index 0000000..0036fb3 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_mixed_incr.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_origin.xml b/app/src/main/res/drawable/ic_ctrl_tv_origin.xml new file mode 100644 index 0000000..509ff07 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_origin.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_pause.xml b/app/src/main/res/drawable/ic_ctrl_tv_pause.xml new file mode 100644 index 0000000..efca3a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_pause.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_play.xml b/app/src/main/res/drawable/ic_ctrl_tv_play.xml new file mode 100644 index 0000000..3d33142 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_play.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_1.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_1.xml new file mode 100644 index 0000000..0c8ad26 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_1.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_2.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_2.xml new file mode 100644 index 0000000..e3342d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_2.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_3.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_3.xml new file mode 100644 index 0000000..af6c354 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_3.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_4.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_4.xml new file mode 100644 index 0000000..0699548 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_4.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_5.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_5.xml new file mode 100644 index 0000000..ee03670 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_5.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_decr.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_decr.xml new file mode 100644 index 0000000..ce487d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_decr.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_female.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_female.xml new file mode 100644 index 0000000..f9c01c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_female.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_incr.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_incr.xml new file mode 100644 index 0000000..a6aa84f --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_incr.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_male.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_male.xml new file mode 100644 index 0000000..8251402 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_male.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_tv_tone_standard.xml b/app/src/main/res/drawable/ic_ctrl_tv_tone_standard.xml new file mode 100644 index 0000000..0922ea1 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_tv_tone_standard.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_volume_minus.xml b/app/src/main/res/drawable/ic_ctrl_volume_minus.xml new file mode 100644 index 0000000..5be8dd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_volume_minus.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ctrl_volume_plus.xml b/app/src/main/res/drawable/ic_ctrl_volume_plus.xml new file mode 100644 index 0000000..3ace673 --- /dev/null +++ b/app/src/main/res/drawable/ic_ctrl_volume_plus.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_download_retry.xml b/app/src/main/res/drawable/ic_download_retry.xml new file mode 100644 index 0000000..963763b --- /dev/null +++ b/app/src/main/res/drawable/ic_download_retry.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drop_down.xml b/app/src/main/res/drawable/ic_drop_down.xml new file mode 100644 index 0000000..2e779c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_drop_down.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drop_down_gray.xml b/app/src/main/res/drawable/ic_drop_down_gray.xml new file mode 100644 index 0000000..e4af953 --- /dev/null +++ b/app/src/main/res/drawable/ic_drop_down_gray.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drop_left.xml b/app/src/main/res/drawable/ic_drop_left.xml new file mode 100644 index 0000000..df6b014 --- /dev/null +++ b/app/src/main/res/drawable/ic_drop_left.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drop_right.xml b/app/src/main/res/drawable/ic_drop_right.xml new file mode 100644 index 0000000..82416be --- /dev/null +++ b/app/src/main/res/drawable/ic_drop_right.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drop_up.xml b/app/src/main/res/drawable/ic_drop_up.xml new file mode 100644 index 0000000..040a41a --- /dev/null +++ b/app/src/main/res/drawable/ic_drop_up.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_kb_clear.xml b/app/src/main/res/drawable/ic_kb_clear.xml new file mode 100644 index 0000000..f29d2c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_clear.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_kb_clear_selected.xml b/app/src/main/res/drawable/ic_kb_clear_selected.xml new file mode 100644 index 0000000..96e1804 --- /dev/null +++ b/app/src/main/res/drawable/ic_kb_clear_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_adaptive_background.xml b/app/src/main/res/drawable/ic_launcher_adaptive_background.xml new file mode 100644 index 0000000..52d785a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_adaptive_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_adaptive_foreground.xml b/app/src/main/res/drawable/ic_launcher_adaptive_foreground.xml new file mode 100644 index 0000000..ea45849 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_adaptive_foreground.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..5d9d1c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_line_selected.xml b/app/src/main/res/drawable/ic_line_selected.xml new file mode 100644 index 0000000..4871eaa --- /dev/null +++ b/app/src/main/res/drawable/ic_line_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_loading.xml b/app/src/main/res/drawable/ic_loading.xml new file mode 100644 index 0000000..355ba16 --- /dev/null +++ b/app/src/main/res/drawable/ic_loading.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_search.xml b/app/src/main/res/drawable/ic_menu_search.xml new file mode 100644 index 0000000..79cd8f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_search.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_settings.xml b/app/src/main/res/drawable/ic_menu_settings.xml new file mode 100644 index 0000000..9fd2905 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_settings.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_shutdown.xml b/app/src/main/res/drawable/ic_menu_shutdown.xml new file mode 100644 index 0000000..03b9b66 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_shutdown.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_skin.xml b/app/src/main/res/drawable/ic_menu_skin.xml new file mode 100644 index 0000000..46fb8cc --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_skin.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_wifi_account.xml b/app/src/main/res/drawable/ic_menu_wifi_account.xml new file mode 100644 index 0000000..8e8fad4 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_wifi_account.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mi_guide_back.xml b/app/src/main/res/drawable/ic_mi_guide_back.xml new file mode 100644 index 0000000..a8c6357 --- /dev/null +++ b/app/src/main/res/drawable/ic_mi_guide_back.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mi_guide_dialog_close.xml b/app/src/main/res/drawable/ic_mi_guide_dialog_close.xml new file mode 100644 index 0000000..4ec8e4d --- /dev/null +++ b/app/src/main/res/drawable/ic_mi_guide_dialog_close.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml new file mode 100644 index 0000000..bd6e548 --- /dev/null +++ b/app/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mobile_upload_song.xml b/app/src/main/res/drawable/ic_mobile_upload_song.xml new file mode 100644 index 0000000..f0f8684 --- /dev/null +++ b/app/src/main/res/drawable/ic_mobile_upload_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..bbbacfd --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_order_song.xml b/app/src/main/res/drawable/ic_order_song.xml new file mode 100644 index 0000000..dbc6540 --- /dev/null +++ b/app/src/main/res/drawable/ic_order_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_ott_delete.xml b/app/src/main/res/drawable/ic_ott_delete.xml new file mode 100644 index 0000000..34ec0a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_ott_delete.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..7e00f45 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pinyin_next.xml b/app/src/main/res/drawable/ic_pinyin_next.xml new file mode 100644 index 0000000..a713b50 --- /dev/null +++ b/app/src/main/res/drawable/ic_pinyin_next.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000..e1ade8b --- /dev/null +++ b/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_random.xml b/app/src/main/res/drawable/ic_random.xml new file mode 100644 index 0000000..e8fbec5 --- /dev/null +++ b/app/src/main/res/drawable/ic_random.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_record.xml b/app/src/main/res/drawable/ic_record.xml new file mode 100644 index 0000000..cdab57c --- /dev/null +++ b/app/src/main/res/drawable/ic_record.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_recording.xml b/app/src/main/res/drawable/ic_recording.xml new file mode 100644 index 0000000..423fcc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_recording.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_reset.xml b/app/src/main/res/drawable/ic_reset.xml new file mode 100644 index 0000000..2b84d82 --- /dev/null +++ b/app/src/main/res/drawable/ic_reset.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_return_order.xml b/app/src/main/res/drawable/ic_return_order.xml new file mode 100644 index 0000000..c0f4944 --- /dev/null +++ b/app/src/main/res/drawable/ic_return_order.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_screen_adjust.xml b/app/src/main/res/drawable/ic_screen_adjust.xml new file mode 100644 index 0000000..46918d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_adjust.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen_adjust_move.xml b/app/src/main/res/drawable/ic_screen_adjust_move.xml new file mode 100644 index 0000000..706d973 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_adjust_move.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search_24dp.xml b/app/src/main/res/drawable/ic_search_24dp.xml new file mode 100644 index 0000000..d31fbea --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24dp.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sing_guide.xml b/app/src/main/res/drawable/ic_sing_guide.xml new file mode 100644 index 0000000..07fada1 --- /dev/null +++ b/app/src/main/res/drawable/ic_sing_guide.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_singer_placeholder.xml b/app/src/main/res/drawable/ic_singer_placeholder.xml new file mode 100644 index 0000000..dcef2b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_singer_placeholder.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_top.xml b/app/src/main/res/drawable/ic_top.xml new file mode 100644 index 0000000..aa8a69d --- /dev/null +++ b/app/src/main/res/drawable/ic_top.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_touch_correct.xml b/app/src/main/res/drawable/ic_touch_correct.xml new file mode 100644 index 0000000..f8a388c --- /dev/null +++ b/app/src/main/res/drawable/ic_touch_correct.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_udisk.xml b/app/src/main/res/drawable/ic_udisk.xml new file mode 100644 index 0000000..e2121ab --- /dev/null +++ b/app/src/main/res/drawable/ic_udisk.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_wifi_off.xml b/app/src/main/res/drawable/ic_wifi_off.xml new file mode 100644 index 0000000..09c1c19 --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_off.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/input_bg.xml b/app/src/main/res/drawable/input_bg.xml new file mode 100644 index 0000000..fb31aa6 --- /dev/null +++ b/app/src/main/res/drawable/input_bg.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/key_popup_number_bg.xml b/app/src/main/res/drawable/key_popup_number_bg.xml new file mode 100644 index 0000000..4a28d3b --- /dev/null +++ b/app/src/main/res/drawable/key_popup_number_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/key_popup_number_normal.xml b/app/src/main/res/drawable/key_popup_number_normal.xml new file mode 100644 index 0000000..4d5cc73 --- /dev/null +++ b/app/src/main/res/drawable/key_popup_number_normal.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/key_popup_number_pressed.xml b/app/src/main/res/drawable/key_popup_number_pressed.xml new file mode 100644 index 0000000..c7dd905 --- /dev/null +++ b/app/src/main/res/drawable/key_popup_number_pressed.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/notification_action_background.xml b/app/src/main/res/drawable/notification_action_background.xml new file mode 100644 index 0000000..8a73cd4 --- /dev/null +++ b/app/src/main/res/drawable/notification_action_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/notification_bg.xml b/app/src/main/res/drawable/notification_bg.xml new file mode 100644 index 0000000..5105590 --- /dev/null +++ b/app/src/main/res/drawable/notification_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/notification_bg_low.xml b/app/src/main/res/drawable/notification_bg_low.xml new file mode 100644 index 0000000..a74fae2 --- /dev/null +++ b/app/src/main/res/drawable/notification_bg_low.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/notification_icon_background.xml b/app/src/main/res/drawable/notification_icon_background.xml new file mode 100644 index 0000000..0ea0d03 --- /dev/null +++ b/app/src/main/res/drawable/notification_icon_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/notification_tile_bg.xml b/app/src/main/res/drawable/notification_tile_bg.xml new file mode 100644 index 0000000..69f6f46 --- /dev/null +++ b/app/src/main/res/drawable/notification_tile_bg.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/orderlist_tab_text_color.xml b/app/src/main/res/drawable/orderlist_tab_text_color.xml new file mode 100644 index 0000000..76edeea --- /dev/null +++ b/app/src/main/res/drawable/orderlist_tab_text_color.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_abc_key.xml b/app/src/main/res/drawable/ott_bg_abc_key.xml new file mode 100644 index 0000000..a769547 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_abc_key.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_abc_key_normal.xml b/app/src/main/res/drawable/ott_bg_abc_key_normal.xml new file mode 100644 index 0000000..d23c05e --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_abc_key_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_abc_key_selected.xml b/app/src/main/res/drawable/ott_bg_abc_key_selected.xml new file mode 100644 index 0000000..0efe168 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_abc_key_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_about_us.xml b/app/src/main/res/drawable/ott_bg_about_us.xml new file mode 100644 index 0000000..2c686e3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_about_us.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_action_button.xml b/app/src/main/res/drawable/ott_bg_action_button.xml new file mode 100644 index 0000000..edeba5b --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_action_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_action_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_action_button_not_selected.xml new file mode 100644 index 0000000..78a6ad8 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_action_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_action_button_selected.xml b/app/src/main/res/drawable/ott_bg_action_button_selected.xml new file mode 100644 index 0000000..75145d6 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_action_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_ai_action_dialog.xml b/app/src/main/res/drawable/ott_bg_ai_action_dialog.xml new file mode 100644 index 0000000..8813519 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_ai_action_dialog.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_ai_dialog_toast.xml b/app/src/main/res/drawable/ott_bg_ai_dialog_toast.xml new file mode 100644 index 0000000..25f4e1c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_ai_dialog_toast.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_ai_guide_item.xml b/app/src/main/res/drawable/ott_bg_ai_guide_item.xml new file mode 100644 index 0000000..ee23183 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_ai_guide_item.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_ai_guide_item_normal.xml b/app/src/main/res/drawable/ott_bg_ai_guide_item_normal.xml new file mode 100644 index 0000000..5a959c0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_ai_guide_item_normal.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_ai_guide_item_selected.xml b/app/src/main/res/drawable/ott_bg_ai_guide_item_selected.xml new file mode 100644 index 0000000..59b46b4 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_ai_guide_item_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_back_button.xml b/app/src/main/res/drawable/ott_bg_back_button.xml new file mode 100644 index 0000000..783ca9b --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_back_button.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_back_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_back_button_not_selected.xml new file mode 100644 index 0000000..f3de7bc --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_back_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_back_button_selected.xml b/app/src/main/res/drawable/ott_bg_back_button_selected.xml new file mode 100644 index 0000000..ae923f0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_back_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_btn_transparent.xml b/app/src/main/res/drawable/ott_bg_btn_transparent.xml new file mode 100644 index 0000000..81ca877 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_btn_transparent.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_btn_transparent_not_selected.xml b/app/src/main/res/drawable/ott_bg_btn_transparent_not_selected.xml new file mode 100644 index 0000000..9032fca --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_btn_transparent_not_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_btn_transparent_selected.xml b/app/src/main/res/drawable/ott_bg_btn_transparent_selected.xml new file mode 100644 index 0000000..a077e40 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_btn_transparent_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_32.xml b/app/src/main/res/drawable/ott_bg_button_32.xml new file mode 100644 index 0000000..f440c3c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_32.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_ai_help_32.xml b/app/src/main/res/drawable/ott_bg_button_ai_help_32.xml new file mode 100644 index 0000000..2748541 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_ai_help_32.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_ai_help_not_selected_32.xml b/app/src/main/res/drawable/ott_bg_button_ai_help_not_selected_32.xml new file mode 100644 index 0000000..cb31019 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_ai_help_not_selected_32.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_exeprience_charge_focused.xml b/app/src/main/res/drawable/ott_bg_button_exeprience_charge_focused.xml new file mode 100644 index 0000000..9a6b61c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_exeprience_charge_focused.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_exeprience_charge_pressed.xml b/app/src/main/res/drawable/ott_bg_button_exeprience_charge_pressed.xml new file mode 100644 index 0000000..2f1ddae --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_exeprience_charge_pressed.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_experience_charge.xml b/app/src/main/res/drawable/ott_bg_button_experience_charge.xml new file mode 100644 index 0000000..e9a26e5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_experience_charge.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_experience_charge_normal.xml b/app/src/main/res/drawable/ott_bg_button_experience_charge_normal.xml new file mode 100644 index 0000000..956060d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_experience_charge_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_free_download.xml b/app/src/main/res/drawable/ott_bg_button_free_download.xml new file mode 100644 index 0000000..7255d16 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_free_download.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_free_download_normal.xml b/app/src/main/res/drawable/ott_bg_button_free_download_normal.xml new file mode 100644 index 0000000..f5acd62 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_free_download_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_free_download_selected.xml b/app/src/main/res/drawable/ott_bg_button_free_download_selected.xml new file mode 100644 index 0000000..4a8b2f4 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_free_download_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_golden_32.xml b/app/src/main/res/drawable/ott_bg_button_golden_32.xml new file mode 100644 index 0000000..fd00b1d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_golden_32.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_golden_not_selected_32.xml b/app/src/main/res/drawable/ott_bg_button_golden_not_selected_32.xml new file mode 100644 index 0000000..fdaafa6 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_golden_not_selected_32.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_license.xml b/app/src/main/res/drawable/ott_bg_button_license.xml new file mode 100644 index 0000000..0964f40 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_license.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_license_not_selected.xml b/app/src/main/res/drawable/ott_bg_button_license_not_selected.xml new file mode 100644 index 0000000..451a5b2 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_license_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_license_selected.xml b/app/src/main/res/drawable/ott_bg_button_license_selected.xml new file mode 100644 index 0000000..2bad798 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_license_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_not_selected_32.xml b/app/src/main/res/drawable/ott_bg_button_not_selected_32.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_not_selected_32.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_selected_32.xml b/app/src/main/res/drawable/ott_bg_button_selected_32.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_selected_32.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item.xml b/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item.xml new file mode 100644 index 0000000..5b11e18 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item_normal.xml b/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item_normal.xml new file mode 100644 index 0000000..63b4125 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item_selected.xml b/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item_selected.xml new file mode 100644 index 0000000..ae923f0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_button_user_mv_tip_item_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_checkbox.xml b/app/src/main/res/drawable/ott_bg_checkbox.xml new file mode 100644 index 0000000..1cb9c23 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_checkbox.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_checkbox_checkmark.xml b/app/src/main/res/drawable/ott_bg_checkbox_checkmark.xml new file mode 100644 index 0000000..0a76dc3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_checkbox_checkmark.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_checkbox_not_selected.xml b/app/src/main/res/drawable/ott_bg_checkbox_not_selected.xml new file mode 100644 index 0000000..068a25c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_checkbox_not_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_checkbox_selected.xml b/app/src/main/res/drawable/ott_bg_checkbox_selected.xml new file mode 100644 index 0000000..25bf3e7 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_checkbox_selected.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_content_item.xml b/app/src/main/res/drawable/ott_bg_content_item.xml new file mode 100644 index 0000000..3ea8a7c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_content_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_content_item_not_selected.xml b/app/src/main/res/drawable/ott_bg_content_item_not_selected.xml new file mode 100644 index 0000000..ecd0aca --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_content_item_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_content_item_selected.xml b/app/src/main/res/drawable/ott_bg_content_item_selected.xml new file mode 100644 index 0000000..37d66cf --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_content_item_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_content_item_transparent.xml b/app/src/main/res/drawable/ott_bg_content_item_transparent.xml new file mode 100644 index 0000000..3c4b6ed --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_content_item_transparent.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_content_item_transparent_focused.xml b/app/src/main/res/drawable/ott_bg_content_item_transparent_focused.xml new file mode 100644 index 0000000..a9ada19 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_content_item_transparent_focused.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog.xml b/app/src/main/res/drawable/ott_bg_dialog.xml new file mode 100644 index 0000000..70f1e9a --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button.xml b/app/src/main/res/drawable/ott_bg_dialog_button.xml new file mode 100644 index 0000000..f02adfd --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_cancel.xml b/app/src/main/res/drawable/ott_bg_dialog_button_cancel.xml new file mode 100644 index 0000000..f02adfd --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_cancel.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_cancel_not_selected.xml b/app/src/main/res/drawable/ott_bg_dialog_button_cancel_not_selected.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_cancel_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_cancel_selected.xml b/app/src/main/res/drawable/ott_bg_dialog_button_cancel_selected.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_cancel_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_confirm.xml b/app/src/main/res/drawable/ott_bg_dialog_button_confirm.xml new file mode 100644 index 0000000..f02adfd --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_confirm.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_confirm_not_selected.xml b/app/src/main/res/drawable/ott_bg_dialog_button_confirm_not_selected.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_confirm_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_confirm_selected.xml b/app/src/main/res/drawable/ott_bg_dialog_button_confirm_selected.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_confirm_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_dialog_button_not_selected.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_button_selected.xml b/app/src/main/res/drawable/ott_bg_dialog_button_selected.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_dialog_transparent.xml b/app/src/main/res/drawable/ott_bg_dialog_transparent.xml new file mode 100644 index 0000000..3e7aa89 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_dialog_transparent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_disco_item.xml b/app/src/main/res/drawable/ott_bg_disco_item.xml new file mode 100644 index 0000000..fa762c4 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_disco_item.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_download.xml b/app/src/main/res/drawable/ott_bg_download.xml new file mode 100644 index 0000000..2f4d34c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_download.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_download_not_selected.xml b/app/src/main/res/drawable/ott_bg_download_not_selected.xml new file mode 100644 index 0000000..3d9741d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_download_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_download_selected.xml b/app/src/main/res/drawable/ott_bg_download_selected.xml new file mode 100644 index 0000000..969a2ab --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_download_selected.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ott_bg_edittext.xml b/app/src/main/res/drawable/ott_bg_edittext.xml new file mode 100644 index 0000000..5c3dfaa --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_edittext.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_flip_page_button.xml b/app/src/main/res/drawable/ott_bg_flip_page_button.xml new file mode 100644 index 0000000..e9886cf --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_flip_page_button.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_flip_page_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_flip_page_button_not_selected.xml new file mode 100644 index 0000000..f3de7bc --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_flip_page_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_flip_page_button_selected.xml b/app/src/main/res/drawable/ott_bg_flip_page_button_selected.xml new file mode 100644 index 0000000..ae923f0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_flip_page_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_image_text_button.xml b/app/src/main/res/drawable/ott_bg_image_text_button.xml new file mode 100644 index 0000000..7547280 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_image_text_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_image_text_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_image_text_button_not_selected.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_image_text_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_image_text_button_selected.xml b/app/src/main/res/drawable/ott_bg_image_text_button_selected.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_image_text_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_input_type_button.xml b/app/src/main/res/drawable/ott_bg_input_type_button.xml new file mode 100644 index 0000000..f440c3c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_input_type_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_input_type_button_focused.xml b/app/src/main/res/drawable/ott_bg_input_type_button_focused.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_input_type_button_focused.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_input_type_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_input_type_button_not_selected.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_input_type_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_input_type_button_selected.xml b/app/src/main/res/drawable/ott_bg_input_type_button_selected.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_input_type_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_item.xml b/app/src/main/res/drawable/ott_bg_item.xml new file mode 100644 index 0000000..bdadfc6 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_item_not_selected.xml b/app/src/main/res/drawable/ott_bg_item_not_selected.xml new file mode 100644 index 0000000..3d9741d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_item_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_item_selected.xml b/app/src/main/res/drawable/ott_bg_item_selected.xml new file mode 100644 index 0000000..a077e40 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_item_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_item_transparent.xml b/app/src/main/res/drawable/ott_bg_item_transparent.xml new file mode 100644 index 0000000..684785d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_item_transparent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_kb_clear.xml b/app/src/main/res/drawable/ott_bg_kb_clear.xml new file mode 100644 index 0000000..1e31b48 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_kb_clear.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_key.xml b/app/src/main/res/drawable/ott_bg_key.xml new file mode 100644 index 0000000..dfc699a --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_key.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_key_normal.xml b/app/src/main/res/drawable/ott_bg_key_normal.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_key_normal.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_key_selected.xml b/app/src/main/res/drawable/ott_bg_key_selected.xml new file mode 100644 index 0000000..0efe168 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_key_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_keyboard_container.xml b/app/src/main/res/drawable/ott_bg_keyboard_container.xml new file mode 100644 index 0000000..ad9e85d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_keyboard_container.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_keyboard_container_normal.xml b/app/src/main/res/drawable/ott_bg_keyboard_container_normal.xml new file mode 100644 index 0000000..dc893bd --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_keyboard_container_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_keyboard_container_selected.xml b/app/src/main/res/drawable/ott_bg_keyboard_container_selected.xml new file mode 100644 index 0000000..50c8156 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_keyboard_container_selected.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_license_qrcode.xml b/app/src/main/res/drawable/ott_bg_license_qrcode.xml new file mode 100644 index 0000000..4c64151 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_license_qrcode.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_list_content.xml b/app/src/main/res/drawable/ott_bg_list_content.xml new file mode 100644 index 0000000..9032fca --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_list_content.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_list_order.xml b/app/src/main/res/drawable/ott_bg_list_order.xml new file mode 100644 index 0000000..9032fca --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_list_order.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_list_rank.xml b/app/src/main/res/drawable/ott_bg_list_rank.xml new file mode 100644 index 0000000..9032fca --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_list_rank.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_list_song.xml b/app/src/main/res/drawable/ott_bg_list_song.xml new file mode 100644 index 0000000..4444aac --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_list_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_menu_tab.xml b/app/src/main/res/drawable/ott_bg_menu_tab.xml new file mode 100644 index 0000000..ddc6ef3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_menu_tab.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_menu_tab_focused.xml b/app/src/main/res/drawable/ott_bg_menu_tab_focused.xml new file mode 100644 index 0000000..b122401 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_menu_tab_focused.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_network_edit_text.xml b/app/src/main/res/drawable/ott_bg_network_edit_text.xml new file mode 100644 index 0000000..5f7c9c7 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_network_edit_text.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_network_edit_text_normal.xml b/app/src/main/res/drawable/ott_bg_network_edit_text_normal.xml new file mode 100644 index 0000000..d96a1c5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_network_edit_text_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_network_edit_text_selected.xml b/app/src/main/res/drawable/ott_bg_network_edit_text_selected.xml new file mode 100644 index 0000000..f132aa3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_network_edit_text_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_network_item.xml b/app/src/main/res/drawable/ott_bg_network_item.xml new file mode 100644 index 0000000..d96a1c5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_network_item.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_order_count.xml b/app/src/main/res/drawable/ott_bg_order_count.xml new file mode 100644 index 0000000..d23b70f --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_order_count.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_permission_button.xml b/app/src/main/res/drawable/ott_bg_permission_button.xml new file mode 100644 index 0000000..bf43b26 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_permission_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_permission_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_permission_button_not_selected.xml new file mode 100644 index 0000000..d96a1c5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_permission_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_permission_button_selected.xml b/app/src/main/res/drawable/ott_bg_permission_button_selected.xml new file mode 100644 index 0000000..fe6ddd9 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_permission_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_permission_dialog.xml b/app/src/main/res/drawable/ott_bg_permission_dialog.xml new file mode 100644 index 0000000..70f1e9a --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_permission_dialog.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_play_ctrl_tip.xml b/app/src/main/res/drawable/ott_bg_play_ctrl_tip.xml new file mode 100644 index 0000000..00f325b --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_play_ctrl_tip.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_playlist_advert_selected.xml b/app/src/main/res/drawable/ott_bg_playlist_advert_selected.xml new file mode 100644 index 0000000..658f0ce --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_playlist_advert_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_playlist_name.xml b/app/src/main/res/drawable/ott_bg_playlist_name.xml new file mode 100644 index 0000000..0ad2140 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_playlist_name.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_rank_border.xml b/app/src/main/res/drawable/ott_bg_rank_border.xml new file mode 100644 index 0000000..30fae6f --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_rank_border.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_rank_item_not_selected.xml b/app/src/main/res/drawable/ott_bg_rank_item_not_selected.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_rank_item_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_rank_item_selected.xml b/app/src/main/res/drawable/ott_bg_rank_item_selected.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_rank_item_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_recharge_option_item.xml b/app/src/main/res/drawable/ott_bg_recharge_option_item.xml new file mode 100644 index 0000000..48bcf9e --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_recharge_option_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_recharge_option_normal.xml b/app/src/main/res/drawable/ott_bg_recharge_option_normal.xml new file mode 100644 index 0000000..25de63f --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_recharge_option_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_recharge_option_price_tip_normal.xml b/app/src/main/res/drawable/ott_bg_recharge_option_price_tip_normal.xml new file mode 100644 index 0000000..6d98716 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_recharge_option_price_tip_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_recharge_option_price_tip_selected.xml b/app/src/main/res/drawable/ott_bg_recharge_option_price_tip_selected.xml new file mode 100644 index 0000000..8466d94 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_recharge_option_price_tip_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_recharge_option_selected.xml b/app/src/main/res/drawable/ott_bg_recharge_option_selected.xml new file mode 100644 index 0000000..e75c90e --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_recharge_option_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_recharge_timer.xml b/app/src/main/res/drawable/ott_bg_recharge_timer.xml new file mode 100644 index 0000000..f25cbee --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_recharge_timer.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_recharge_vip_content.xml b/app/src/main/res/drawable/ott_bg_recharge_vip_content.xml new file mode 100644 index 0000000..ed8607f --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_recharge_vip_content.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_red_dot.xml b/app/src/main/res/drawable/ott_bg_red_dot.xml new file mode 100644 index 0000000..81a5e36 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_red_dot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_search_edittext.xml b/app/src/main/res/drawable/ott_bg_search_edittext.xml new file mode 100644 index 0000000..c8e57d3 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_search_edittext.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_search_edittext_selected.xml b/app/src/main/res/drawable/ott_bg_search_edittext_selected.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_search_edittext_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_select_border.xml b/app/src/main/res/drawable/ott_bg_select_border.xml new file mode 100644 index 0000000..afd354f --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_select_border.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_select_border_selected.xml b/app/src/main/res/drawable/ott_bg_select_border_selected.xml new file mode 100644 index 0000000..8ab7cb6 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_select_border_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_serial_no_button.xml b/app/src/main/res/drawable/ott_bg_serial_no_button.xml new file mode 100644 index 0000000..210f5fa --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_serial_no_button.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_serial_no_button_normal.xml b/app/src/main/res/drawable/ott_bg_serial_no_button_normal.xml new file mode 100644 index 0000000..ad5919d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_serial_no_button_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_serial_no_button_selected.xml b/app/src/main/res/drawable/ott_bg_serial_no_button_selected.xml new file mode 100644 index 0000000..08d2d79 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_serial_no_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_singer_list_item.xml b/app/src/main/res/drawable/ott_bg_singer_list_item.xml new file mode 100644 index 0000000..cfb1de8 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_singer_list_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_singer_list_item_normal.xml b/app/src/main/res/drawable/ott_bg_singer_list_item_normal.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_singer_list_item_normal.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_singer_list_item_selected.xml b/app/src/main/res/drawable/ott_bg_singer_list_item_selected.xml new file mode 100644 index 0000000..6a2f987 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_singer_list_item_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_song_lang.xml b/app/src/main/res/drawable/ott_bg_song_lang.xml new file mode 100644 index 0000000..b525306 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_song_lang.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_button_28.xml b/app/src/main/res/drawable/ott_bg_tab_button_28.xml new file mode 100644 index 0000000..a091f1c --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_button_28.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_button_not_selected_28.xml b/app/src/main/res/drawable/ott_bg_tab_button_not_selected_28.xml new file mode 100644 index 0000000..4b712d2 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_button_not_selected_28.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_button_selected_28.xml b/app/src/main/res/drawable/ott_bg_tab_button_selected_28.xml new file mode 100644 index 0000000..ae923f0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_button_selected_28.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_lang_layout.xml b/app/src/main/res/drawable/ott_bg_tab_lang_layout.xml new file mode 100644 index 0000000..5a959c0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_lang_layout.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_layout.xml b/app/src/main/res/drawable/ott_bg_tab_layout.xml new file mode 100644 index 0000000..f3de7bc --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_layout.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_page_list.xml b/app/src/main/res/drawable/ott_bg_tab_page_list.xml new file mode 100644 index 0000000..a9ffb63 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_page_list.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_page_list_not_selected.xml b/app/src/main/res/drawable/ott_bg_tab_page_list_not_selected.xml new file mode 100644 index 0000000..9032fca --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_page_list_not_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tab_page_list_selected.xml b/app/src/main/res/drawable/ott_bg_tab_page_list_selected.xml new file mode 100644 index 0000000..30cf4d5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tab_page_list_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_text_image_button.xml b/app/src/main/res/drawable/ott_bg_text_image_button.xml new file mode 100644 index 0000000..9dbfd23 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_text_image_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_text_image_button_not_selected.xml b/app/src/main/res/drawable/ott_bg_text_image_button_not_selected.xml new file mode 100644 index 0000000..451a5b2 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_text_image_button_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_text_image_button_selected.xml b/app/src/main/res/drawable/ott_bg_text_image_button_selected.xml new file mode 100644 index 0000000..80253df --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_text_image_button_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tpl_item.xml b/app/src/main/res/drawable/ott_bg_tpl_item.xml new file mode 100644 index 0000000..54f3c4f --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tpl_item.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tpl_item_not_selected.xml b/app/src/main/res/drawable/ott_bg_tpl_item_not_selected.xml new file mode 100644 index 0000000..5a959c0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tpl_item_not_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tpl_item_selected.xml b/app/src/main/res/drawable/ott_bg_tpl_item_selected.xml new file mode 100644 index 0000000..1aa31bc --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tpl_item_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_transparent.xml b/app/src/main/res/drawable/ott_bg_transparent.xml new file mode 100644 index 0000000..5a959c0 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_transparent.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tv_action_tip.xml b/app/src/main/res/drawable/ott_bg_tv_action_tip.xml new file mode 100644 index 0000000..e1bbfdc --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tv_action_tip.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tv_ctrl_item.xml b/app/src/main/res/drawable/ott_bg_tv_ctrl_item.xml new file mode 100644 index 0000000..b63197a --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tv_ctrl_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tv_ctrl_item_not_selected.xml b/app/src/main/res/drawable/ott_bg_tv_ctrl_item_not_selected.xml new file mode 100644 index 0000000..3d9741d --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tv_ctrl_item_not_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tv_ctrl_item_selected.xml b/app/src/main/res/drawable/ott_bg_tv_ctrl_item_selected.xml new file mode 100644 index 0000000..a077e40 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tv_ctrl_item_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_tv_layout_selected.xml b/app/src/main/res/drawable/ott_bg_tv_layout_selected.xml new file mode 100644 index 0000000..d3e6231 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_tv_layout_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_video_download_error_button.xml b/app/src/main/res/drawable/ott_bg_video_download_error_button.xml new file mode 100644 index 0000000..c352d7e --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_video_download_error_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_video_download_error_button_normal.xml b/app/src/main/res/drawable/ott_bg_video_download_error_button_normal.xml new file mode 100644 index 0000000..ab07744 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_video_download_error_button_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_volume_ctrl_action_button.xml b/app/src/main/res/drawable/ott_bg_volume_ctrl_action_button.xml new file mode 100644 index 0000000..d7306fb --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_volume_ctrl_action_button.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ott_bg_volume_ctrl_action_normal.xml b/app/src/main/res/drawable/ott_bg_volume_ctrl_action_normal.xml new file mode 100644 index 0000000..d2326f8 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_volume_ctrl_action_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_volume_ctrl_action_selected.xml b/app/src/main/res/drawable/ott_bg_volume_ctrl_action_selected.xml new file mode 100644 index 0000000..3431ed5 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_volume_ctrl_action_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_button.xml b/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_button.xml new file mode 100644 index 0000000..6b27963 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_normal.xml b/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_normal.xml new file mode 100644 index 0000000..01123e1 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_selected.xml b/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_selected.xml new file mode 100644 index 0000000..46d00fe --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_volume_ctrl_mute_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_wifi_item.xml b/app/src/main/res/drawable/ott_bg_wifi_item.xml new file mode 100644 index 0000000..61b8000 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_wifi_item.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_wifi_item_focused.xml b/app/src/main/res/drawable/ott_bg_wifi_item_focused.xml new file mode 100644 index 0000000..3f54b89 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_wifi_item_focused.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_wifi_item_normal.xml b/app/src/main/res/drawable/ott_bg_wifi_item_normal.xml new file mode 100644 index 0000000..b5d9118 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_wifi_item_normal.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_bg_wifi_item_pressed.xml b/app/src/main/res/drawable/ott_bg_wifi_item_pressed.xml new file mode 100644 index 0000000..a077e40 --- /dev/null +++ b/app/src/main/res/drawable/ott_bg_wifi_item_pressed.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_about_us.xml b/app/src/main/res/drawable/ott_ic_about_us.xml new file mode 100644 index 0000000..da876ca --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_about_us.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_agreement.xml b/app/src/main/res/drawable/ott_ic_agreement.xml new file mode 100644 index 0000000..8683bb5 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_agreement.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ai.xml b/app/src/main/res/drawable/ott_ic_ai.xml new file mode 100644 index 0000000..fd34317 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ai.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_arrow_down.xml b/app/src/main/res/drawable/ott_ic_arrow_down.xml new file mode 100644 index 0000000..ca41143 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_arrow_down.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_arrow_up.xml b/app/src/main/res/drawable/ott_ic_arrow_up.xml new file mode 100644 index 0000000..3e4d419 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_arrow_up.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_auto_remove_space.xml b/app/src/main/res/drawable/ott_ic_auto_remove_space.xml new file mode 100644 index 0000000..98d8cf6 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_auto_remove_space.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_back.xml b/app/src/main/res/drawable/ott_ic_back.xml new file mode 100644 index 0000000..5290136 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_back.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_boot_logo.xml b/app/src/main/res/drawable/ott_ic_boot_logo.xml new file mode 100644 index 0000000..b029a7e --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_boot_logo.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_checkmark.xml b/app/src/main/res/drawable/ott_ic_checkmark.xml new file mode 100644 index 0000000..98ac4f4 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_checkmark.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_clear_recent.xml b/app/src/main/res/drawable/ott_ic_clear_recent.xml new file mode 100644 index 0000000..0090704 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_clear_recent.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_count.xml b/app/src/main/res/drawable/ott_ic_count.xml new file mode 100644 index 0000000..c1c1bf9 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_count.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_music_accomp.xml b/app/src/main/res/drawable/ott_ic_ctrl_music_accomp.xml new file mode 100644 index 0000000..44883f5 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_music_accomp.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_music_origin.xml b/app/src/main/res/drawable/ott_ic_ctrl_music_origin.xml new file mode 100644 index 0000000..d2df568 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_music_origin.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_orderlist.xml b/app/src/main/res/drawable/ott_ic_ctrl_orderlist.xml new file mode 100644 index 0000000..18fd8b0 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_orderlist.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_pause.xml b/app/src/main/res/drawable/ott_ic_ctrl_pause.xml new file mode 100644 index 0000000..bf9a213 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_pause.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_play.xml b/app/src/main/res/drawable/ott_ic_ctrl_play.xml new file mode 100644 index 0000000..9bc9951 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_play.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_replay.xml b/app/src/main/res/drawable/ott_ic_ctrl_replay.xml new file mode 100644 index 0000000..b1ec722 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_replay.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_score.xml b/app/src/main/res/drawable/ott_ic_ctrl_score.xml new file mode 100644 index 0000000..7719068 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_score.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_switch_song.xml b/app/src/main/res/drawable/ott_ic_ctrl_switch_song.xml new file mode 100644 index 0000000..24434e3 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_switch_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ctrl_volume.xml b/app/src/main/res/drawable/ott_ic_ctrl_volume.xml new file mode 100644 index 0000000..a6775c2 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ctrl_volume.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_delete.xml b/app/src/main/res/drawable/ott_ic_delete.xml new file mode 100644 index 0000000..c91d2b3 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_delete.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_double_screen.xml b/app/src/main/res/drawable/ott_ic_double_screen.xml new file mode 100644 index 0000000..7edb7d4 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_double_screen.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_download.xml b/app/src/main/res/drawable/ott_ic_download.xml new file mode 100644 index 0000000..b14ad1c --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_download.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_download_setting.xml b/app/src/main/res/drawable/ott_ic_download_setting.xml new file mode 100644 index 0000000..cacec77 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_download_setting.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ethernet_disconnect.xml b/app/src/main/res/drawable/ott_ic_ethernet_disconnect.xml new file mode 100644 index 0000000..bb5184a --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ethernet_disconnect.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_ethernet_setting.xml b/app/src/main/res/drawable/ott_ic_ethernet_setting.xml new file mode 100644 index 0000000..328c0ab --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_ethernet_setting.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_exit.xml b/app/src/main/res/drawable/ott_ic_exit.xml new file mode 100644 index 0000000..cfa8255 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_exit.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_favorite.xml b/app/src/main/res/drawable/ott_ic_favorite.xml new file mode 100644 index 0000000..1521cc5 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_favorite.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_favorite_cancel.xml b/app/src/main/res/drawable/ott_ic_favorite_cancel.xml new file mode 100644 index 0000000..52caf9e --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_favorite_cancel.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_file.xml b/app/src/main/res/drawable/ott_ic_file.xml new file mode 100644 index 0000000..1473406 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_file.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_file_not_exist.xml b/app/src/main/res/drawable/ott_ic_file_not_exist.xml new file mode 100644 index 0000000..338360f --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_file_not_exist.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_fullscreen.xml b/app/src/main/res/drawable/ott_ic_fullscreen.xml new file mode 100644 index 0000000..5677346 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_fullscreen.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_gift.xml b/app/src/main/res/drawable/ott_ic_gift.xml new file mode 100644 index 0000000..f7da285 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_gift.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_info.xml b/app/src/main/res/drawable/ott_ic_info.xml new file mode 100644 index 0000000..e26d341 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_info.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_kb_clear.xml b/app/src/main/res/drawable/ott_ic_kb_clear.xml new file mode 100644 index 0000000..4547006 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_kb_clear.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_kb_delete.xml b/app/src/main/res/drawable/ott_ic_kb_delete.xml new file mode 100644 index 0000000..62248b7 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_kb_delete.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_language.xml b/app/src/main/res/drawable/ott_ic_language.xml new file mode 100644 index 0000000..a1a8324 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_language.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_license.xml b/app/src/main/res/drawable/ott_ic_license.xml new file mode 100644 index 0000000..ab6e00b --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_license.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_license_dark.xml b/app/src/main/res/drawable/ott_ic_license_dark.xml new file mode 100644 index 0000000..d1c663a --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_license_dark.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_marquee_setting.xml b/app/src/main/res/drawable/ott_ic_marquee_setting.xml new file mode 100644 index 0000000..85a6acd --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_marquee_setting.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_minus.xml b/app/src/main/res/drawable/ott_ic_minus.xml new file mode 100644 index 0000000..325a2d7 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_minus.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_mobile_qrcode.xml b/app/src/main/res/drawable/ott_ic_mobile_qrcode.xml new file mode 100644 index 0000000..71759eb --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_mobile_qrcode.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_more.xml b/app/src/main/res/drawable/ott_ic_more.xml new file mode 100644 index 0000000..0201f93 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_more.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_mute.xml b/app/src/main/res/drawable/ott_ic_mute.xml new file mode 100644 index 0000000..4661dad --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_mute.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_network.xml b/app/src/main/res/drawable/ott_ic_network.xml new file mode 100644 index 0000000..2acb149 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_network.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_network_disconnect.xml b/app/src/main/res/drawable/ott_ic_network_disconnect.xml new file mode 100644 index 0000000..b2546cf --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_network_disconnect.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_network_test.xml b/app/src/main/res/drawable/ott_ic_network_test.xml new file mode 100644 index 0000000..ca46d50 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_network_test.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_network_test_fail.xml b/app/src/main/res/drawable/ott_ic_network_test_fail.xml new file mode 100644 index 0000000..ecacb90 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_network_test_fail.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_network_test_ok.xml b/app/src/main/res/drawable/ott_ic_network_test_ok.xml new file mode 100644 index 0000000..b1b2662 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_network_test_ok.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_network_test_waiting.xml b/app/src/main/res/drawable/ott_ic_network_test_waiting.xml new file mode 100644 index 0000000..48e10ba --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_network_test_waiting.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_pitch_female.xml b/app/src/main/res/drawable/ott_ic_pitch_female.xml new file mode 100644 index 0000000..474312c --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_pitch_female.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_pitch_male.xml b/app/src/main/res/drawable/ott_ic_pitch_male.xml new file mode 100644 index 0000000..0883c05 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_pitch_male.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_pitch_reset.xml b/app/src/main/res/drawable/ott_ic_pitch_reset.xml new file mode 100644 index 0000000..be190a8 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_pitch_reset.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_play.xml b/app/src/main/res/drawable/ott_ic_play.xml new file mode 100644 index 0000000..46144a4 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_play.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_play_while_download.xml b/app/src/main/res/drawable/ott_ic_play_while_download.xml new file mode 100644 index 0000000..2efcb96 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_play_while_download.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_player_state_error.xml b/app/src/main/res/drawable/ott_ic_player_state_error.xml new file mode 100644 index 0000000..972c737 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_player_state_error.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_playing.xml b/app/src/main/res/drawable/ott_ic_playing.xml new file mode 100644 index 0000000..3adfb2c --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_playing.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_plus.xml b/app/src/main/res/drawable/ott_ic_plus.xml new file mode 100644 index 0000000..df2d7aa --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_plus.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_pub_play.xml b/app/src/main/res/drawable/ott_ic_pub_play.xml new file mode 100644 index 0000000..968aa10 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_pub_play.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_recharge_timer_divider.xml b/app/src/main/res/drawable/ott_ic_recharge_timer_divider.xml new file mode 100644 index 0000000..fdae5ad --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_recharge_timer_divider.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_recharge_vip.xml b/app/src/main/res/drawable/ott_ic_recharge_vip.xml new file mode 100644 index 0000000..805ca9b --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_recharge_vip.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_recharge_vip_selected.xml b/app/src/main/res/drawable/ott_ic_recharge_vip_selected.xml new file mode 100644 index 0000000..a47fb67 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_recharge_vip_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_room_ctrl.xml b/app/src/main/res/drawable/ott_ic_room_ctrl.xml new file mode 100644 index 0000000..d3bb8ce --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_room_ctrl.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_rotation.xml b/app/src/main/res/drawable/ott_ic_rotation.xml new file mode 100644 index 0000000..52e1c52 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_rotation.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_scan.xml b/app/src/main/res/drawable/ott_ic_scan.xml new file mode 100644 index 0000000..4bcd82a --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_scan.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_score.xml b/app/src/main/res/drawable/ott_ic_score.xml new file mode 100644 index 0000000..74adba9 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_score.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_search.xml b/app/src/main/res/drawable/ott_ic_search.xml new file mode 100644 index 0000000..b3d99c5 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_search.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_settings.xml b/app/src/main/res/drawable/ott_ic_settings.xml new file mode 100644 index 0000000..48a6b06 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_settings.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_settings_pwd.xml b/app/src/main/res/drawable/ott_ic_settings_pwd.xml new file mode 100644 index 0000000..36b1f9b --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_settings_pwd.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_skin_setting.xml b/app/src/main/res/drawable/ott_ic_skin_setting.xml new file mode 100644 index 0000000..c22d873 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_skin_setting.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_song_recommend.xml b/app/src/main/res/drawable/ott_ic_song_recommend.xml new file mode 100644 index 0000000..a1aaf17 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_song_recommend.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_sound.xml b/app/src/main/res/drawable/ott_ic_sound.xml new file mode 100644 index 0000000..bb80bf9 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_sound.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_sync.xml b/app/src/main/res/drawable/ott_ic_sync.xml new file mode 100644 index 0000000..09d4077 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_sync.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_ai_help.xml b/app/src/main/res/drawable/ott_ic_tab_ai_help.xml new file mode 100644 index 0000000..fd34317 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_ai_help.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_app.xml b/app/src/main/res/drawable/ott_ic_tab_app.xml new file mode 100644 index 0000000..be71fb2 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_app.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_disco.xml b/app/src/main/res/drawable/ott_ic_tab_disco.xml new file mode 100644 index 0000000..782bdff --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_disco.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_language.xml b/app/src/main/res/drawable/ott_ic_tab_language.xml new file mode 100644 index 0000000..c99dff7 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_language.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_market.xml b/app/src/main/res/drawable/ott_ic_tab_market.xml new file mode 100644 index 0000000..92b9948 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_market.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_order_song.xml b/app/src/main/res/drawable/ott_ic_tab_order_song.xml new file mode 100644 index 0000000..27e11f5 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_order_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_recommend.xml b/app/src/main/res/drawable/ott_ic_tab_recommend.xml new file mode 100644 index 0000000..b1cd120 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_recommend.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tab_setting.xml b/app/src/main/res/drawable/ott_ic_tab_setting.xml new file mode 100644 index 0000000..d174a33 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tab_setting.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tip.xml b/app/src/main/res/drawable/ott_ic_tip.xml new file mode 100644 index 0000000..b81ab5a --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tip.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_top.xml b/app/src/main/res/drawable/ott_ic_top.xml new file mode 100644 index 0000000..62f741f --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_top.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_tv_display_mode.xml b/app/src/main/res/drawable/ott_ic_tv_display_mode.xml new file mode 100644 index 0000000..47be15c --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_tv_display_mode.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_udisk_add_song.xml b/app/src/main/res/drawable/ott_ic_udisk_add_song.xml new file mode 100644 index 0000000..0ee0854 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_udisk_add_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_udisk_config.xml b/app/src/main/res/drawable/ott_ic_udisk_config.xml new file mode 100644 index 0000000..d5ea648 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_udisk_config.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_udisk_play.xml b/app/src/main/res/drawable/ott_ic_udisk_play.xml new file mode 100644 index 0000000..19c0bf8 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_udisk_play.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_upgrade.xml b/app/src/main/res/drawable/ott_ic_upgrade.xml new file mode 100644 index 0000000..c25e584 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_upgrade.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_video_broadcast.xml b/app/src/main/res/drawable/ott_ic_video_broadcast.xml new file mode 100644 index 0000000..27c2c2c --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_video_broadcast.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_vip_agreement.xml b/app/src/main/res/drawable/ott_ic_vip_agreement.xml new file mode 100644 index 0000000..ed51b05 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_vip_agreement.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_wifi.xml b/app/src/main/res/drawable/ott_ic_wifi.xml new file mode 100644 index 0000000..666872e --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_wifi.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_wifi_free.xml b/app/src/main/res/drawable/ott_ic_wifi_free.xml new file mode 100644 index 0000000..0a00b57 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_wifi_free.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_ic_wifi_setting.xml b/app/src/main/res/drawable/ott_ic_wifi_setting.xml new file mode 100644 index 0000000..8f578b9 --- /dev/null +++ b/app/src/main/res/drawable/ott_ic_wifi_setting.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_input_type_button.xml b/app/src/main/res/drawable/ott_input_type_button.xml new file mode 100644 index 0000000..f440c3c --- /dev/null +++ b/app/src/main/res/drawable/ott_input_type_button.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_back.xml b/app/src/main/res/drawable/ott_tv_ic_back.xml new file mode 100644 index 0000000..a0c1756 --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_back.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_music_accomp.xml b/app/src/main/res/drawable/ott_tv_ic_music_accomp.xml new file mode 100644 index 0000000..b7762e2 --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_music_accomp.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_music_origin.xml b/app/src/main/res/drawable/ott_tv_ic_music_origin.xml new file mode 100644 index 0000000..5b02b53 --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_music_origin.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_orderlist.xml b/app/src/main/res/drawable/ott_tv_ic_orderlist.xml new file mode 100644 index 0000000..2f26be2 --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_orderlist.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_pause.xml b/app/src/main/res/drawable/ott_tv_ic_pause.xml new file mode 100644 index 0000000..6267f4c --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_pause.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_play.xml b/app/src/main/res/drawable/ott_tv_ic_play.xml new file mode 100644 index 0000000..274701a --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_play.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_replay.xml b/app/src/main/res/drawable/ott_tv_ic_replay.xml new file mode 100644 index 0000000..e078326 --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_replay.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_switch_song.xml b/app/src/main/res/drawable/ott_tv_ic_switch_song.xml new file mode 100644 index 0000000..5a7532e --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_switch_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ott_tv_ic_volume.xml b/app/src/main/res/drawable/ott_tv_ic_volume.xml new file mode 100644 index 0000000..a9304a7 --- /dev/null +++ b/app/src/main/res/drawable/ott_tv_ic_volume.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_bar_style.xml b/app/src/main/res/drawable/progress_bar_style.xml new file mode 100644 index 0000000..9aef9bc --- /dev/null +++ b/app/src/main/res/drawable/progress_bar_style.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/skin_btn_check.xml b/app/src/main/res/drawable/skin_btn_check.xml new file mode 100644 index 0000000..5fc59f3 --- /dev/null +++ b/app/src/main/res/drawable/skin_btn_check.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/skin_btn_check_box.xml b/app/src/main/res/drawable/skin_btn_check_box.xml new file mode 100644 index 0000000..cee81ab --- /dev/null +++ b/app/src/main/res/drawable/skin_btn_check_box.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/skin_btn_check_box_outline_blank.xml b/app/src/main/res/drawable/skin_btn_check_box_outline_blank.xml new file mode 100644 index 0000000..e8b6961 --- /dev/null +++ b/app/src/main/res/drawable/skin_btn_check_box_outline_blank.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/skin_btn_radio.xml b/app/src/main/res/drawable/skin_btn_radio.xml new file mode 100644 index 0000000..4dffbfe --- /dev/null +++ b/app/src/main/res/drawable/skin_btn_radio.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/skin_btn_radio_checked.xml b/app/src/main/res/drawable/skin_btn_radio_checked.xml new file mode 100644 index 0000000..f0cc3e6 --- /dev/null +++ b/app/src/main/res/drawable/skin_btn_radio_checked.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/skin_btn_radio_unchecked.xml b/app/src/main/res/drawable/skin_btn_radio_unchecked.xml new file mode 100644 index 0000000..90f38f6 --- /dev/null +++ b/app/src/main/res/drawable/skin_btn_radio_unchecked.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_selected.xml b/app/src/main/res/drawable/tab_selected.xml new file mode 100644 index 0000000..90731cf --- /dev/null +++ b/app/src/main/res/drawable/tab_selected.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/tab_unselected.xml b/app/src/main/res/drawable/tab_unselected.xml new file mode 100644 index 0000000..fb5acf5 --- /dev/null +++ b/app/src/main/res/drawable/tab_unselected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/tooltip_frame_dark.xml b/app/src/main/res/drawable/tooltip_frame_dark.xml new file mode 100644 index 0000000..b233d93 --- /dev/null +++ b/app/src/main/res/drawable/tooltip_frame_dark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tooltip_frame_light.xml b/app/src/main/res/drawable/tooltip_frame_light.xml new file mode 100644 index 0000000..39bb937 --- /dev/null +++ b/app/src/main/res/drawable/tooltip_frame_light.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_more.xml b/app/src/main/res/drawable/tpl_tab_more.xml new file mode 100644 index 0000000..32bdb3f --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_more.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_more_selected.xml b/app/src/main/res/drawable/tpl_tab_more_selected.xml new file mode 100644 index 0000000..ca891c1 --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_more_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_movie.xml b/app/src/main/res/drawable/tpl_tab_movie.xml new file mode 100644 index 0000000..fa386b4 --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_movie.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_movie_selected.xml b/app/src/main/res/drawable/tpl_tab_movie_selected.xml new file mode 100644 index 0000000..26acd12 --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_movie_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_order_song.xml b/app/src/main/res/drawable/tpl_tab_order_song.xml new file mode 100644 index 0000000..1726de5 --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_order_song.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_order_song_selected.xml b/app/src/main/res/drawable/tpl_tab_order_song_selected.xml new file mode 100644 index 0000000..a723442 --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_order_song_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_recommend.xml b/app/src/main/res/drawable/tpl_tab_recommend.xml new file mode 100644 index 0000000..c681c51 --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_recommend.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tpl_tab_recommend_selected.xml b/app/src/main/res/drawable/tpl_tab_recommend_selected.xml new file mode 100644 index 0000000..104ab4d --- /dev/null +++ b/app/src/main/res/drawable/tpl_tab_recommend_selected.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/white_radius.xml b/app/src/main/res/drawable/white_radius.xml new file mode 100644 index 0000000..4f89252 --- /dev/null +++ b/app/src/main/res/drawable/white_radius.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..1b327b2 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,874 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_audio_control.xml b/app/src/main/res/layout/dialog_audio_control.xml new file mode 100644 index 0000000..bac2358 --- /dev/null +++ b/app/src/main/res/layout/dialog_audio_control.xml @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_settings.xml b/app/src/main/res/layout/dialog_settings.xml new file mode 100644 index 0000000..ff4c077 --- /dev/null +++ b/app/src/main/res/layout/dialog_settings.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_song_recommend.xml b/app/src/main/res/layout/dialog_song_recommend.xml new file mode 100644 index 0000000..4504f3e --- /dev/null +++ b/app/src/main/res/layout/dialog_song_recommend.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_tuning.xml b/app/src/main/res/layout/dialog_tuning.xml new file mode 100644 index 0000000..7d9dbf5 --- /dev/null +++ b/app/src/main/res/layout/dialog_tuning.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/settings_menu_item.xml b/app/src/main/res/layout/settings_menu_item.xml new file mode 100644 index 0000000..593e588 --- /dev/null +++ b/app/src/main/res/layout/settings_menu_item.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a4c223e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/raw/isrg_root_x1.pem b/app/src/main/res/raw/isrg_root_x1.pem new file mode 100644 index 0000000..b85c803 --- /dev/null +++ b/app/src/main/res/raw/isrg_root_x1.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/app/src/main/res/raw/isrg_root_yr.pem b/app/src/main/res/raw/isrg_root_yr.pem new file mode 100644 index 0000000..af1526c --- /dev/null +++ b/app/src/main/res/raw/isrg_root_yr.pem @@ -0,0 +1,84 @@ +-----BEGIN CERTIFICATE----- +MIIF9DCCA9ygAwIBAgIRAPJLbRf52a18scn+p4eCaZ8wDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjYwNTEzMDAwMDAw +WhcNMzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQ +MA4GA1UEAxMHUm9vdCBZUjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +ANvGJnN78CTJdWL3+eGfsLN5TrNBJs+VH9hRXqRbwxu9sGNiB0BD1fcOxbSUQCJI +M1xE13Db+5Cw1w0s0EBYsvuIP/6joF0w8cuImbgR1OGgYbSQ4OpzI+DG8SGuTlcE +873OCS+kh3srlo6vl43M5OJg4Aeo1sfHp6kTJDoIiFBNJAY+OKfX/FUvYKuhjT+n +o49lmqmupSBI5PkBQiqrEGtWU5uxU/cQWHGu8jSjFBznZqvbNPLMXMLFxCb3WTfr +JBXXjqvWG+v4bjzxjjeAtOlU7qarRDvNOyAuQYLln904M+faKx8hnLCpJ15ZqaEg +cNlY+9MMWcC5yvL2A2j3l9+2buggZX+dOE91zYmIdawTvSZuVvlbRrAlLxIB6pwM +BjneXCjYQ8+3BCCjssbSNpZU3hTcBDdhfAlEDlYr6pEatnMdmDT5BqnKC92bd0Eh +M1fbLHioLccLCuievT8ZkPhZrq7Mii7gNXAcUEAR8+lzYal+9zTg7C5DALyVOeG/ +CqfRAMn1KSHCR0NSA6P8tn/mGRlnCct5rtVCLnVySVpU6H1qGg3DgTOuskf8eahT +MiYbI5ezPJmO5ertalskQ1utp74+eDy92PI4ftHKTbq9IWhH4YZKh3WnJEIt+oQv +lYZbY8tpEroKrFB6PFGzrJIDRyts4HqvuH52RFj2zv/BAgMBAAGjgeswgegwDgYD +VR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFN7nW2DQIm1AKH0/DQH+pLVStFGUMB8GA1UdIwQYMBaAFHm0 +WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYW +aHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAECATAnBgNV +HR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMA0GCSqGSIb3DQEB +CwUAA4ICAQA8spSI95KKfn2W6GMmDpHBJSPaLbsS3W93cijJCRCYAc1fsJgL1FIL +7C0C9ecPOdcwB2fi0Dk2p94j9iTJCxmt5CFSKLRWwnXT2MMSXexVxqoVB79BdWPx +VXETkVme/qYSAuKVHh5Ps+5BixgmwS1JkjSAc+MfrUbNssVEEnH0aEiAh+rotXAV +JSP/Ye7LJPEwD9DWG72vVWbhAcuOf5OLjz57Ctk7MgQHynZ7+PlHJtajroCaIbtC +r6tcZZaAwUQm+jQyeWdV+2hv9deOYFmKeQyjjcSrN5Nadrw+L9DZJLbA1HqeNvLh +BgqpP0fvJq2N6EtD574N6eMI7uMsJTnji2UDz9el5XLSv9fqJMuDQtYVb2oTNoKp +oUqhxPVC0aq4eG5MESaIdn8b5ZGSSeAJLMHXljEdlNza+ncfkviXk1POLnnFdvx8 +/gk6M374WbLWFXw8N141B/Rl/tINGfl1TxOIiqtiMYkL02RSGb1kq34BL9NPP27z +RGMuHGnzS3hFIrRTfKxrzUZ9RzQWzEG3K6fJ3r2nqSltkeytis9DIBoFY9VmVyjL +M71DMi+y1+TRSJVClEMwvA4yL++7q9XZx5r5wBRWB4kQTKH5qyoZnDw7iiuh1lID +yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA== +-----END CERTIFICATE----- +--- +Server certificate +subject=CN=*.cherryonline.cn +issuer=C=US, O=Let's Encrypt, CN=YR1 +--- +No client certificate CA names sent +Peer signing digest: SHA512 +Peer signature type: rsa_pkcs1_sha512 +Peer Temp Key: ECDH, prime256v1, 256 bits +--- +SSL handshake has read 4768 bytes and written 1689 bytes +Verification: OK +--- +New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256 +Protocol: TLSv1.2 +Server public key is 2048 bit +Secure Renegotiation IS supported +Compression: NONE +Expansion: NONE +No ALPN negotiated +SSL-Session: + Protocol : TLSv1.2 + Cipher : ECDHE-RSA-AES128-GCM-SHA256 + Session-ID: B1DA6A720785DFE34864DBC3E1150ADE19ECBDCAEA75D467BCC22DEE0F993867 + Session-ID-ctx: + Master-Key: FBC94665B1E887DFA409F820CD45E6C0B2A868B13D2A7DA96AE563F8A85746327BDD8D1710A78F4731E11E901AE50D50 + PSK identity: None + PSK identity hint: None + SRP username: None + TLS session ticket lifetime hint: 300 (seconds) + TLS session ticket: + 0000 - 77 df 30 97 af 87 e4 cf-f5 ef 9d f6 1b 97 44 e7 w.0...........D. + 0010 - 24 0a 29 89 e4 3f b1 55-ae d8 8a 51 4a 24 a2 8e $.)..?.U...QJ$.. + 0020 - 55 04 f8 19 8e 96 10 25-b4 91 5a ed d0 e0 03 32 U......%..Z....2 + 0030 - 94 20 17 17 3d 4b 56 96-07 53 cf e0 76 3e c9 4a . ..=KV..S..v>.J + 0040 - 93 b1 5f ed 65 7f 29 98-d9 a9 42 e3 fd 6e 1a 4c .._.e.)...B..n.L + 0050 - 05 be 40 5a 15 24 30 ef-1c 99 5b 42 37 91 79 c1 ..@Z.$0...[B7.y. + 0060 - f9 2c 54 ad d9 6c 68 75-5e 16 d5 27 29 78 fe 26 .,T..lhu^..')x.& + 0070 - 6a c3 f6 f2 67 9d 77 6b-1e 98 1f bd ce f0 a6 59 j...g.wk.......Y + 0080 - 8e 1d 3f a8 1d 5c 6a 4c-58 d1 12 14 85 1f 41 73 ..?..\jLX.....As + 0090 - 68 38 55 3a 3a dc 8e 11-6e 90 ad ba 1d 57 a9 23 h8U::...n....W.# + 00a0 - c2 c1 4a 2a e0 ac 91 8f-cf 02 98 d7 65 d6 7f 08 ..J*........e... + 00b0 - 8b f3 b1 d6 37 49 f9 a6-10 f4 08 1d ae f3 9d 9b ....7I.......... + 00c0 - bb a2 21 21 e8 d7 a2 57-75 54 be 6c d8 5a 4d a3 ..!!...WuT.l.ZM. + + Start Time: 1788193688 + Timeout : 7200 (sec) + Verify return code: 0 (ok) + Extended master secret: no +--- diff --git a/app/src/main/res/raw/letsencrypt_yr1.pem b/app/src/main/res/raw/letsencrypt_yr1.pem new file mode 100644 index 0000000..7fd4d58 --- /dev/null +++ b/app/src/main/res/raw/letsencrypt_yr1.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIE2zCCAsOgAwIBAgIRAKICU/FfJpHAXcHOE7m8yk4wDQYJKoZIhvcNAQELBQAw +LjELMAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxEDAOBgNVBAMTB1Jvb3QgWVIw +HhcNMjUwOTAzMDAwMDAwWhcNMjgwOTAyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEW +MBQGA1UEChMNTGV0J3MgRW5jcnlwdDEMMAoGA1UEAxMDWVIxMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoVi8X2xCYgMXvJxNPKp/oF13UMgmPABB07VC +LNDtoXmt9luEZNJSBV10VyT1Pz6LD8Zq1d2gc43WNl1AdRrj4sEnazbOiz0nPpmG +Bp2hui49oZtDIY6wdKeZAi5BbNU20CH6RSBBMLSQ9cXrH8dxdv4PAJ45ssGML68U +SE3BsjC2a6cAN9L5CgXVIQi5tfNiTPoFZZ3S0OlXqLmmtdV95udWAb5b6e/F49Di +CsH0Y00Ag72BVIb1hzynmKe+X0mERBTtsb3BwmpV9ipeBjMLoR/D9cHxHQCWoi5l +TmXwY015J5rGelz1nZjJuxc2kioaX29XJBnhMkP531rSdG5uMwIDAQABo4HuMIHr +MA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDATASBgNVHRMBAf8E +CDAGAQH/AgEAMB0GA1UdDgQWBBQfLzW+RhSCzUCxrnksVXj699Ro+zAfBgNVHSME +GDAWgBTe51tg0CJtQCh9Pw0B/qS1UrRRlDAyBggrBgEFBQcBAQQmMCQwIgYIKwYB +BQUHMAKGFmh0dHA6Ly95ci5pLmxlbmNyLm9yZy8wEwYDVR0gBAwwCjAIBgZngQwB +AgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3lyLmMubGVuY3Iub3JnLzANBgkq +hkiG9w0BAQsFAAOCAgEA0+zvMq3kHig1ddTmmm+RibTr9/RpX7k4buanMMRqbV/y +IvP82zAHN3mvaw+cASuVsdpd0ikjhr4hnhJQLQOzOp2ccKrsdGOAgo0vddeISFAq +EWEV4lmUM3vFF796up+bSgmJ1u6RupDCMxDgF8M3eLvGuj6L0lu3zkQ0KuQLnKxL +tB0oQqn1Idg5CuuGpMvQzk29Pa3D/qHurc0EIM9SxukQuJqq63lxsYyRQFU8yMBO +hq1w5LbfaWNRrz1uklOfI/pYkAb2E2MTZrAMQkBIE2S8Jt1F8gRc96o/xOsrgvSk +a84AisX6xq1lz1Z7jGvrnXc4TMcjxZTjiTaihcYI1JIXZiLtEMSCa5l3cu8YWd6z +dLRQlqRdclVjuQfNHawRJ6GWlkK0QJosivTKwdBw3KxEtzGo8yMHERbsy57gP1UX +HOMcmZYQC0gtyR3SxfenIM/MxC3Ia2Ypab/kQ/CTnlIn2KQ5JUC6NYrGCbhFN9bp +5lKJStEwCUnLpntcrXk5XVDCNv/5RyWpRThkGOV7GetKkQ0qAY8hCzWK6oqnAhDZ +cjlYVdWfqOw3DIOX6EDNBgAqHarRVxyF9QZdOaXSyPJ0ueD2BYJEBgaCGQ8rAaU/ +Qc123V5LTXDZW4CcsPBDyhy4v+c8hClAyw/IkJlfBqxB9D+/wvIMHgECZ4ptP6o= +-----END CERTIFICATE----- + 2 s:C=US, O=ISRG, CN=Root YR + i:C=US, O=Internet Security Research Group, CN=ISRG Root X1 + a:PKEY: RSA, 4096 (bit); sigalg: sha256WithRSAEncryption + v:NotBefore: May 13 00:00:00 2026 GMT; NotAfter: Sep 2 23:59:59 2032 GMT diff --git a/app/src/main/res/raw/mili_log.crt b/app/src/main/res/raw/mili_log.crt new file mode 100644 index 0000000..b85c803 --- /dev/null +++ b/app/src/main/res/raw/mili_log.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 0000000..84c1ce5 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,19 @@ + + + + com.hhc.muse.device + com.hhc.muse.manager + com.hhc.muse.launcher + com.hhc.muse.watch + com.google.android.inputmethod.latin + io.titannetwork.service + + + com.alphainventor.filemanager + com.android.chrome + + + com.alphainventor.filemanager + com.android.chrome + + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..0658d78 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..34dfe54 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,188 @@ + + + @android:color/black + #ff7fa87f + @android:color/black + @android:color/black + @color/material_deep_teal_200 + @color/material_deep_teal_500 + #1f000000 + #8a000000 + @color/material_grey_800 + @android:color/white + @color/material_grey_850 + @color/material_grey_50 + #336440ce + #ffbebebe + #99000000 + #33855bff + #00000000 + #66ffffff + #80ffffff + #80000000 + @color/bright_foreground_material_light + @color/bright_foreground_material_dark + @android:color/white + @android:color/black + #ff5a595b + #ffd6d7d7 + #ffd81b60 + #ff008577 + #ff00574b + #ff969696 + #ffffdec1 + #b3000217 + #66000000 + #ff000000 + #ff3a2001 + #ff818181 + #66000000 + #99000000 + #b3000000 + #e6000000 + #ff3e71f9 + #ff20296a + #b320296a + #ff60587f + #ff582907 + #ffc1c1c1 + #fff8d200 + #fffff08b + #ffeabc7f + #66ffffff + #fff1e3ff + #e6ffffff + #ff222222 + #33222222 + #1af1f1f1 + #ff515155 + #80f1f1f1 + #b3515155 + #ff25252b + #ff999999 + #ffcb4628 + #ffff4021 + #fff6443b + #ffb40d37 + #fffabb35 + #ff09d2df + #ff2e1d73 + #ff2d006f + #ff9b5bef + #ff3d2688 + #ffe364f6 + #ffcc56f3 + #ffe6dffd + #4d855bff + #ff9faae7 + #fffd3359 + #fffd4633 + #80fd4633 + #ff0000ff + #ff00ff00 + #ffff0000 + #fff74c45 + #ffcd88e6 + #ff00ffd8 + #ff5db8ff + #ffabdcff + #ff0898ff + #ffffb7db + #fff6456f + #ffde4313 + #fffec163 + #fffbde38 + #ffffa01e + #ffff8221 + #99000000 + #00000000 + #4d000000 + #66000000 + #80000000 + #99000000 + #b0000000 + #e6000000 + #ffffffff + #1affffff + #fffefeff + #33ffffff + #fff1f1f1 + #4dffffff + #80f1f1f1 + #b3f1f1f1 + #fffef3be + #ffffffff + #ffe1e1e1 + #80ffffff + #99ffffff + #b3ffffff + #ccffffff + #e6ffffff + #aaffffff + #ffffb340 + #ffc39758 + #80c39758 + #ff87582a + #ffffe363 + #ff7a492f + #ffffc17a + #ff712011 + #ff7f4f29 + #ffb25206 + #80bebebe + #80323232 + #ffbebebe + #ff323232 + #ff39c5bb + #ffff7043 + #ffff5722 + @android:color/white + @android:color/black + #6680cbc4 + #66009688 + #26ffffff + #ffff4081 + #ff9e9e9e + #809e9e9e + #809e9e9e + #fff8bbd0 + #ff37474f + #ff263238 + #ff21272b + #ff80cbc4 + #ff009688 + #fff5f5f5 + #ffe0e0e0 + #fffafafa + #ff757575 + #ff424242 + #ff303030 + #ff212121 + #ffffffff + #ff9e9e9e + #ff424242 + @android:color/black + @color/material_grey_600 + @color/material_grey_900 + @color/material_grey_100 + #ffffffff + #de000000 + #4dffffff + #39000000 + #33ffffff + #1f000000 + #b3ffffff + #8a000000 + #36ffffff + #24000000 + #3e000000 + #ff616161 + #ffbdbdbd + #ffbdbdbd + #fff1f1f1 + #e6616161 + #e6ffffff + #00000000 + #ffabffff + #fff2ff20 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..935fecd --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,186 @@ + + + 16.0dip + 72.0dip + 56.0dip + 0.0dip + 0.0dip + 4.0dip + 16.0dip + 10.0dip + 6.0dip + 48.0dip + 180.0dip + 5.0dip + -3.0dip + 48.0dip + 48.0dip + 36.0dip + 48.0dip + 48.0dip + @dimen/abc_control_inset_material + 6.0dip + 8.0dip + @dimen/abc_control_padding_material + 720.0dip + 320.0dip + 2.0dip + 4.0dip + 4.0dip + 2.0dip + 79.99999% + 100.0% + 320.0dip + 320.0dip + 8.0dip + 8.0dip + 65.0% + 95.00001% + 24.0dip + 18.0dip + 8.0dip + 0.3 + 0.26 + 32.0dip + 8.0dip + 8.0dip + 7.0dip + 4.0dip + 10.0dip + 16.0dip + @dimen/abc_action_bar_content_inset_material + 296.0dip + 4.0dip + 48.0dip + 320.0dip + 2.0dip + 2.0dip + 20.0dip + 0.0px + 14.0sp + 14.0sp + 14.0sp + 12.0sp + 34.0sp + 45.0sp + 56.0sp + 112.0sp + 24.0sp + 22.0sp + 18.0sp + 14.0sp + 16.0sp + 14.0sp + 16.0sp + 16.0dip + 20.0sp + 20.0dip + 36.0dip + 16.0sp + 4.0dip + 6.0dip + 8.0dip + 4.0dip + 2.0dip + 320.0dip + 320.0dip + 8.0dip + 0.3 + 0.26 + 46.0dip + 12.0dip + 1.0dip + 14.0dip + 14.0dip + 16.0sp + 14.0sp + 10.0dip + 50.0dip + 8.0dip + 0.0dip + 50.0dip + 24.0sp + 16.0sp + 22.0sp + 14.0sp + 20.0sp + 18.0sp + 0.26 + 0.2 + 0.12 + 0.5 + 0.38 + 0.7 + 0.54 + 20.0dip + 800.0dip + 120.0dip + 64.0dip + 40.0dip + 53.0dip + 10.0dip + 16.0dip + 30.0dip + 16.0sp + 12.0sp + 40.0dip + 1.0dip + 32.0dip + 13.0sp + 12.0dip + 8.0dip + 64.0dip + 64.0dip + 10.0dip + @dimen/notification_content_margin_start + 16.0dip + 4.0dip + 3.0dip + 24.0dip + 13.0sp + 10.0dip + 5.0dip + 55.0dip + 70.0dip + 10.0dip + 14.0sp + 15.0sp + 14.0dip + 10.0dip + 8.0dip + 8.0dip + 18.0dip + 8.0dip + 20.0dip + 18.0sp + 2.0dip + 2.0dip + 2.0dip + 2.0dip + 11.0sp + 12.0sp + 13.0sp + 14.0sp + 15.0sp + 16.0sp + 17.0sp + 18.0sp + 20.0sp + 21.0sp + 22.0sp + 24.0sp + 25.0sp + 26.0sp + 28.0sp + 30.0sp + 34.0sp + 8.0sp + 2.0dip + 16.0dip + 8.0dip + 8.0dip + 96.0dip + 6.5dip + 0.0dip + 16.0dip + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..c347b99 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1338820 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,1545 @@ + + + a-cache + Navigate home + Navigate up + More options + Done + See all + Choose an app + OFF + ON + sans-serif + sans-serif-medium + sans-serif-medium + sans-serif + sans-serif + sans-serif + sans-serif + sans-serif-light + sans-serif + sans-serif + sans-serif + sans-serif-medium + Alt+ + Ctrl+ + delete + enter + Function+ + Meta+ + Shift+ + space + Sym+ + Menu+ + Search… + Clear query + Search query + Search + Submit query + Voice search + Share with + Share with %s + Collapse + 立即认证 + 请输入序列号认证 + 请使用微信扫描二维码,先关注公众号,再序列号认证 + 请输入序列号 + 认证成功 + 认证失败,请输入正确的序列号 + 选择以下方式进行序列号认证 + 方法一: + 方法二: + 日志 + 文件下载:%1$d 次, 成功:%2$d 次, 失败:%3$d 次 + 设备IP:%1$s + 设备MAC:%1$s + 点歌软件版本:v%1$s + 固件版本:%1$s + 服务器:%1$s + 开始时间:%1$s + 点击坐标:(%.2f, %.2f) + 视频播放:%1$d 次, 成功:%2$d 次, 失败:%3$d 次 + 同意并继续 + 已为您切换 + 已为您停止 + 已为您继续 + 已回放当前歌曲 + 切换成功 + 已为您置顶歌曲 + 音量降低 + 音量增加 + 已为您添加歌曲 + 当前网络异常,连接网络可以使用更多的语音功能哦~ + 小爱没有听懂 + 网络异常,请检查网络设置 + 正在获取收藏的歌曲...... + 正在获取已点歌曲...... + 正在获取常唱歌曲...... + 正在获取已唱歌曲...... + "你可以说【第几首】选择歌曲 +【上一页】【下一页】翻页 +【关闭页面】退出" + %1$s 的歌 + %1$s的《%2$s》 + 《%1$s》 + 正在搜索歌曲中...... + 正在搜索:%1$s + "Hi,我是小爱同学,您的语音助手。 + +您可以对话筒说【小爱同学】唤起我,然后对我说对应的语音命令。 + +连接网络后才能使用小爱同学~" + 歌曲正在下载中 + 未能找到歌曲 + 正在搜索歌曲 + 请选择歌曲 + 语音功能已启动,您可以开始点歌了 + 语音功能已关闭 + 已为您置顶《%1$s》 + 未找到任何已点歌曲 + "你可以说【第几首】来置顶歌曲 +【上一页】【下一页】翻页 +【关闭页面】退出" + 选择语音助手类型 + 您可以选择想要使用的语音助手类型,修改之后APP将会自动重启: + 语音助手修改成功,应用即将自动重启! + 免唤醒语音助手 + 小爱同学 + 我没听清,再说一遍吧 + + 我来啦 + 我在 + 在呢 + 全部 + 应用发生错误,我们会尽快处理 + 麦动 + 应用未安装在系统目录下,为了您的安全,APP禁止使用。 + 应用商店 + 暂时没有任何应用 + %1$s 暂未安装,是否立即下载安装? + 下载中 + 升级中 + 等待中 + 安装中… + %1$s 不可卸载 + %1$s安装成功,是否立即打开? + 卸载失败,请重试! + 是否要卸载%1$s? + 卸载成功! + 卸载中 + 智能灯光 + 气氛 + 唯美 + 明亮 + 朦胧 + 关闭 + 抒情 + 摇滚 + 伤感 + 灯光设置 + 慢摇 + 柔和 + 4路灯光 + 6路灯光 + 8路灯光 + 灯光类型已修改为:%1$s + 音画同步 + 声音提前 %1$s 秒 + 声音延后 %1$s 秒 + 音画同步 + 重唱或切歌后生效 + 测试版本授权已过期,请联系厂商更新对应软件版本! + 存储空间不足,已自动删除较少演唱的歌曲《%1$s》,建议您插上U盘或移动硬盘,用来保存更多歌曲。 + 存储空间不足,已自动删除《%1$s》等 %2$d 首较少演唱的歌曲,建议您插上U盘或移动硬盘,用来保存更多歌曲。 + 存储空间不足,已自动删除《%1$s》等 %2$d 首歌曲 + 跳过 + 跳过本次绑定,您可以在【设置】页面中继续绑定设备 + 微信扫码绑定设备 + bit_rate + 语音助手 + ABC + ㄓㄨㄧㄣ + 手写 + 系统 + 注音 + 小爱同学 + 下一页 + 上一页 + 屏幕旋转 + 设置 + 警告! 内部存储空间不足,可能会导致播放异常,请及时删除不需要的歌曲或清理多余的文件和APP! + 呼叫 + 呼叫服务失败 + 呼叫服务生 + 拿%1$d个%2$s + 关闭 + 取消呼叫 + 正在呼叫服务,确定取消? + 设置屏幕方向,即将重启… + 检查更新失败,请重试 + 已是最新版本 + 关闭边下边播 + 服务器已超过%s未联网,建议联网获取最新的曲库信息 + 无法删除该应用 + 存在升级包包含此安装包,无法删除 + 服务器授权已过期 + 检测到新的存储盘,即将为您自动扫描该存储盘 + 正在扫描%1$s下的歌曲,扫描进度(%2$s/%3$s),请您耐心等候,不要关机 + 自动删歌后,存储空间仍不足%1$s,歌曲无法存储 + 自动删歌后,磁盘空间仍不足%1$s,请清理部分多余文件后重试 + 存储空间不足,已自动删除歌曲《%1$s》 + 初始化结束,成功为您导入%1$s首歌曲 + 正在初始化数据库,导入歌曲进度(%1$s/%2$s),请您耐心等待,不要关机 + 正在扫描硬盘并初始化数据库 + 正在扫描歌曲文件,扫描进度(%s/%s),请您耐心等待,不要关机 + 服务器授权即将到期 + 无法删除最近两天内在线的机顶盒 + 当前包厢尚未开台, 请联系服务员 + 二维码已过期 + 该IP已被使用,请使用其他IP + 点歌数量已超上限 + 未绑定设备,请扫码绑定 + 点唱机未连接网络,暂不支持小程序点歌 + 二维码格式错误 + 正在校验U盘加歌数据,进度%1$s/%2$s + 未连接到服务器:%1$s + 文件夹创建失败,请确认硬盘是否松动 + 正在校验数据库数据 + 正在进行曲库备份 + 数据库备份失败,请稍后重试 + 数据库备份失败,未检测到外部存储 + 数据库备份失败,外部存储空间不足%1$s,请尝试手动清理后重试 + 正在复制曲库文件 + 您查询的数据可能发生损坏,建议通过升级数据库修复该问题 + 正在初始化曲库(%1$s) + 曲库已是最新版本 + 正在创建索引 + 正在同步曲库(%1$s/%2$s) + 曲库同步已取消,本次共更新%1$s首歌曲 + 正在取消更新...... + 已存在曲库同步任务,请取消之前的任务后重试 + 正在同步热度值,如需暂停,请在公众号中操作 + 曲库更新成功,本次共更新%1$s首歌曲 + 数据库文件解压失败,请尝试重启设备后重试 + 正在升级曲库 + 升级曲库成功 + 正在扫描歌曲文件 + 存储 %1$s 下载异常,本次运行将暂时禁用该硬盘 + 未获得路径:%1$s的挂载信息,请尝试拔插存储设备或重启app + 文件存储出错,请根据界面提示进行修复! + 扫描失败,请检硬盘是否松动并重试 + 扫描结束,成功导入%1$s首歌曲 + 磁盘空间不足%1$s,请清理部分多余文件后重试 + 未检测到存储器或存储器容量小于%1$s,请检查您的硬盘,U盘或TF卡是否损坏 + 未检测到存储器或存储器容量小于%1$s,请检查您的硬盘,U盘或TF卡是否损坏,APP即将在 {{}} 秒后退出… + 下载已取消 + 数据库文件下载失败,请尝试重启设备后重试 + 正在下载数据库文件(%1$s) + 网络异常,请检查网络连接是否正常,系统稍后将自动为您重新下载数据库文件 + 下载错误,您可尝试重启设备后重试,若问题仍未解决,请联系客服 + 下载已暂停 + 密码错误 + 系统异常,请稍后再试 + 系统异常 + 文件正在下载 + 文件不存在 + 文件读取错误,您可尝试重启设备后重试,若问题仍未解决,请联系客服 + 文件读取错误 + 删除文件失败,请稍后重试 + 获取本机集群信息,发生数据库异常,请尝试重启服务 + 获取硬盘目录异常 + 获取下载链接失败,参数错误 + 获取下载链接失败,请检查网络 + 获取下载链接失败,请检查网络 + 获取下载链接失败,请重试 + 获取下载链接失败,云端错误 + 获取下载链接失败,请稍后重试 + 文件写入错误,可能为磁盘坏道引起,建议更换一块存储重新尝试 + 数据库错误,您可尝试重启设备后重试,若问题仍未解决,请联系客服 + 数据库错误 + 网络请求错误,请检查网络连接后重试 + 网络请求错误 + HTTP请求返回错误,请稍后重试 + HTTP请求返回错误 + 数据格式错误 + 数据格式错误 + 请求参数错误 + 请求参数错误 + 存在一样或更新的vc版本升级包, 无法创建 + 未设置歌曲下载的硬盘 + 请勿输入违法违规内容 + 局域网内存在多个场所集群,无法正常提供服务,请确保局域网中的服务器处于同一场所 + 曲库下载授权即将到期 + 曲库下载授权已到期 + MV资源不可用! + 网络异常,请检查网络连接后重试 + 功能暂未开放 + 对象已存在 + 对象不存在 + 无法激活,请联系购买经销商! + 当前设备【%1$s】型号【%2$s】无法使用此渠道的应用! + 设备流量已用完,请扫码续费! + 设备已激活! + 无法激活设备,设备必须先至少联网过一次才能激活! + 设备授权已过期,请扫码续费! + 当前设备【%1$s】型号【%2$s】不支持安装此渠道的应用! + 设备流量已用完,请扫码激活! + 设备未验证序列号 + 已登录设备超过授权机顶盒最大数量 + 设备在其他场所绑定中 + 场所服务器未绑定 + 场所服务器不在线 + 非场所管理员, 无权限访问 + 歌单歌曲超过上限 + 二维码已过期,请刷新 + 二维码未扫描,请扫描二维码 + 歌手不能删除 + 歌手不能更新 + 歌手有关联歌曲 + 歌曲不能删除 + 歌曲不能更新 + 二维码已被使用,请刷新 + 未找到该记录,建议执行设置-数据同步,以更新本地数据 + 请求过于频繁,请稍后再试 + 正在恢复U盘加歌数据%1$s/%2$s + 成功恢复%1$s首歌曲数据 + 集群服务暂未启动,如长时间未启动,请检查网络是否异常 + 请使用利通收银开关台 + 内部存储空间不足%1$s,请清理部分多余文件后重试 + 正在搜寻局域网内集群服务 + 保存主存储设置失败,请稍后重试 + 皮肤包下载失败,请检查网络 + 切换皮肤失败,请重试 + 验证码错误 + 下载失败,歌曲《%1$s》已下架 + 正在请求云端数据,请确保网络连接 + 正在同步数据 + 正在同步数据 + 系统正忙,请稍后再试 + 请求超时,请稍后重试 + TODO + 正在复制U盘歌曲,当前进度%1$s/%2$s,歌曲 %3$s + 硬盘空间不足,已为您暂停加歌,成功加歌%1$s首 + 加歌成功,已为您加歌%1$s首 + 加歌成功,发现重复歌曲%1$s首,已为您新加歌曲%2$s首 + 加歌成功,发现重复歌曲%1$s首,未发现新歌 + 扫描结束,未发现U盘加歌歌曲 + 正在隐藏U盘加歌数据,进度%s/%s + 正在扫描U盘歌曲,已扫描%1$s首歌曲 + 正在扫描U盘歌曲,已扫描%1$s首歌曲,自动为您过滤%2$s首重复歌曲 + 正在扫描U盘歌曲 + 集群通信服务未启动,请稍后重试 + 服务器未获取到授权信息 + 未授权 + 未知的文件类型,无法下载 + 正在解压文件(%1$s) + 正在扫描%1$s下的歌曲,扫描进度(%2$s/%3$s) + 正在努力导入歌单中,请稍后刷新收藏歌单列表查看导入的歌单 + 未找到视频文件,请重新上传 + 确认 + 确认退出系统? + 立即退出 + 无法获取到有线网卡MAC! + 立即重启 + 立即更新 + 连接 + 要校准触摸屏,请按住白色十字中心,直到它消失. + 校准完成. + 本地无校准数据,要校准触摸屏,请按住白色十字中心,直到它消失. + 未进行校准,使用原有数据. + 未进行校准,触摸屏可能无法正常工作. + 高级音效 + 气氛 + 返回点歌 + 小爱 + 伴唱 + 原唱 + 已点 + 暂停 + 幻影 + 播放 + 重唱 + 评分 + 调音 + 切歌 + 调音 + 导唱 + 当前版本: + 检测到数据损坏,即将重新下载数据库进行修复 + + 删除 + 检查网络 + 系统不支持竖屏,请重启设备后继续使用 + 当前设备已被禁用,若有疑问请联系经销商处理,点击确认退出APP或检查网络连接 + 检测到存储设备接入 + KTV + 酒吧 + 餐吧 + 家庭 + 沐足 + 多功能厅 + 娱乐室 + 选择使用场景,我们将更好为您服务 + 检测到存储设备移除 + 设置网络 + 会员激活成功 + 不同意 + 派对模式下无法使用皮肤功能 + 退出派对模式? + 关闭派对模式,可继续欢唱 + 请勿退出APP + 继续下载 + 下载失败,%1$s + 下载失败 + 下载失败,请重试 + 下载失败 + 下载失败,重试 + 重试 + 正在从云端下载歌曲:%1$s + 未找到分类 + 未找到歌手 + 未找到歌曲 + + 立刻充值 + · 每日可免费下载歌曲 %1$d首 + (剩余%1$d首) + · 歌曲下载速度限速 %1$dKB/s + 体验卡权限: + %1$s到期 + 未找到文件,请检查U盘是否正常接入 + 筛选 +  / %1$d + ,APP即将在 %1$d 秒后退出… + fps + "*: Not in backStack +☀: Visible" + Stack + "按菜单键 +切换全屏" + v%1$s + 语音 + 小爱 + 应用 + 派对 + 主页 + 语言 + 超市 + 点歌 + 推荐 + 设置 + 命令词 + 伴唱 + 关闭页面 + 打开全屏 + 退出全屏 + 音量已最大 + 麦克风音量降低 + 麦克风音量增加 + 下一首 + 下一页 + 打开收藏列表 + 打开本地歌曲列表 + 打开已点列表 + 打开常唱列表 + 打开已唱列表 + 原唱 + 暂停 + 播放 + 上一首 + 上一页 + 静音 + 打开声音 + 重唱 + 选择序号 + 设置音量: + 置顶歌曲 + 音量降低 + 音量增加 + 来一首 + 我要唱 + 点首 + 已添加歌曲 + 已添加歌曲:%1$s + 我在听…… + 我没有听懂 + 我没听清,再说一遍吧 + 当前没有收藏歌曲~ + 已点歌曲列表为空~ + 没有找到任何常唱歌曲~ + 已唱歌曲列表为空~ + 播放控制 + "1. 暂停播放 +    “暂停、停止播放、暂停播放” + +2. 继续播放 +    “播放、继续播放、恢复播放” + +" + "3. 切下一首歌 +    “下一首、切歌、下一曲、下首、下曲” + +    无网络情况:“下一首” + +4. 重唱 +    “重播、重唱、再唱一次” + +    无网络情况:“再唱一次”" + 无网络 + 有网络 + 点歌 + "只有连接了网络才能使用点歌功能哦~ + +" + "1. 按歌手 + 歌名点歌: +   【点个/点首/来一首/我要唱/我想唱/帮我点】+【歌手名】的【歌曲名】 +   例如:“我想唱周华健的朋友” + +" + "2. 按歌手点歌: +   【点个/点首/来一首/我要唱/我想唱/帮我点】+【歌手名】的歌 +   例如:“点首周华健的歌” + +" + "3. 按歌名点歌: +   【点个/点首/来一首/我要唱/我想唱/帮我点】+【歌名】 +   例如:“来一首朋友” + +" + "您可以通过这些命令选择想唱的歌曲: + +    选择歌曲:您可以说【第几首】选择歌曲,例如:“第一首 / 第五首” +    翻页:您可以说 “上一页”、“下一页” +    退出:您可以说 “关闭页面”" + 窗口控制 + "1. 全屏播放视频 +    “打开全屏、进入全屏” + +2. 退出全屏播放 +    “退出全屏、关闭全屏、结束全屏”" + 静音 + "如果要关闭声音,您可以这么说: +“关闭声音、静音、关掉声音、声音关闭、取消声音、安静” + +无网络情况:“关闭声音” + +" + 降低音量 + "您可以通过这些命令来调高音量: + +“声音小一点、音量小一点、音乐小一点、音量调小点、声音调小点 +小声一点、音量小点、音乐小点、小声点、小点声、音量调小一点 +声音调小一点、音量小一些、音量再小一些、音乐小一些、音乐再小一些 +音乐再小一点” + +无网络情况:“声音小一点、音量小一点、音乐小一点”" + 音量最大 + "您可以通过这些命令把音量开到最大: + +“声音最大、音量最大” + +" + 只有连接了网络才能使用这个功能。 + 打开声音 + "如果要恢复声音,您可以这么说: +“打开声音、声音恢复、恢复声音、正常声音、声音正常、取消静音、退出静音、关闭静音” + +无网络情况:“打开声音” + +" + 设置任意音量 + "设置音量为任意数值,您可以这么说: +【设置音量 / 音量调到】+【0~100的数字】 + +例如:“设置音量三十五”、“音量调到四十” + +" + 只有连接了网络才能使用这个功能。 + 增加音量 + "您可以通过这些命令来增加音量: + +“声音大一点、音量大一点、音乐大一点、音量调大点、声音调大点 +音量调大一点、声音调大一点、大声一点、大声点、大点声、音量大一些 +音乐大一些、音乐再大一些、音量再大一些、音乐再大一点” + +无网络情况:“声音大一点、音量大一点、音乐大一点”" + 功能说明 + "Hi,我是您的语音助手,您可以直接对麦克风说出想要的命令,我会执行对应操作。 + +建议连接网络后再使用语音功能。 + +无网络情况会影响识别准确率,并且只能使用一部分命令。" + 原伴唱 + "1. 视频切到原唱 +    “原唱” + +2. 视频切到伴唱 +    “伴唱”" + "Hi,我是您的语音助手,您可以直接对麦克风说出想要的命令。 + +您可以这么和我说: + +1. 点个/点首/来一首【某个歌手】的【歌名】 +   (例如:点首“周杰伦”的“七里香”) +2. 点个/点首【歌名】 +   (例如:点首 “七里香”) +3. 下一首 +4. 播放 / 暂停 +5. 声音大一点 / 声音小一点 +6. 设为音量【0—100的数值】 +   (例如:设置音量50) +7. 原唱 / 伴唱 +8. 重唱 +9. 静音 / 关闭声音 +10. 打开声音 / 恢复声音 +11. 打开全屏 +12. 退出全屏" + 目录 + 网络异常,请检查网络设置 + 云端服务异常:%s + 正在下载歌曲 + 正在下载歌曲:%1$s + 未能找到歌曲 + "未能找到歌曲:%1$s + +推荐使用 “[歌手名] + [歌曲名]” 的说法点歌哦~ + +比如说:“%2$s周华健的《朋友》”" + 已置顶歌曲:《%1$s》 + 效果器:%1$s 型号: %2$s + 利通:%1$s + 小爱Token:%1$s + 网络:%1$s    %2$s + 录音接口:%1$s + Rom版本:%1$s + 关台 + 已开台 + 服务器:%1$s + 存储:%1$d/%2$dM    + 正在检查资源更新 … + 初始化完成,正在打开主页 … + 建立长连接 + 正在连接本地服务器 … + 正在检查设备状态 + 登录中 … + 正在校准系统时间 … + 正在搜索本地服务器 … + 正在获取环境配置 … + 正在获取系统配置 … + 正在检查资源 … + 系统初始化中 … + 加载数据… + 初始化中 … + APP即将在 %1$d 秒后退出… + + Vince Mi + https://github.com/vinc3m1 + true + A fast ImageView (and Drawable) that supports rounded corners (and ovals or circles) based on the original example from Romain Guy. + RoundedImageView + 1.3.0 + https://github.com/vinc3m1/RoundedImageView + apache_2_0 + https://github.com/vinc3m1/RoundedImageView.git + 设备尚未激活,若您是消费者,请激活设备,畅享点歌 + 暂不激活 + 激活 + 网络异常,请重试 + 激活失败 + 激活成功 + 请先扫码免费开通会员! + 下载次数已用完,请及时续费! + 当前会员已过期,请及时续费! + 您的会员已过期,请洽谈代理商 + 您的会员权益即将到期,剩余 %1$d天,请提前续费 + "您的会员权益即将到期,剩余 %1$d天 +请提前洽谈代理商" + 正在加载... + 剩余下载数:%1$d首 + 畅享歌曲下载特权 + VIP · 续费 + 请您尽快扫码续费 + %1$s到期 + 当前会员已经过期 + 免费下载 + (会员即将到期) + 下载次数已用完 + + 麦动 + 会员有效期: + 微信扫码免费开通会员 + 微信扫码续费 + load-cost + 加载中… + 本地影片 + 暂无歌词 + 欢迎光临,关台状态不能点歌哦~ + 欢迎光临 + 欢迎光临,您当前还没有点歌哦~ + 当前:%1$s - %2$s + 缓冲中 + 当前没有下载 + 下载中:%1$s (%2$s) + 当前:%1$s - %2$s\u0009\u0009下首:%3$s - %4$s + 当前:%1$s - %2$s\u0009\u0009下首:未点歌 + 下首:%1$s - %2$s + 下首:未点歌 + 当前:未点歌 + 已添加歌曲:%s + 添加歌曲成功 + 伴唱 + 已为您呼叫服务生 + 已通知服务生拿%1$d个%2$s + 关闭空调 + 关闭页面 + 打开全屏 + 退出全屏 + 帮助 + "您可以这么和我说: +1. 下一首/切歌 +2. 点一首【某位歌手】的【歌名】 + (例如:点一首“周华健”的“朋友”) +3. 播放/暂停 +4. 静音/声音还原 +5. 重唱/原唱/伴唱 +6. 声音大一点/声音小一点 +7. 麦克风音量大一点/麦克风音量小一点 +8. 音调升高/音调降低 +9. 切换灯光/灯光柔和/灯光明亮等" + 说笑话中 + 灯光效果改变 + 关闭灯光 + 默认灯光 + 麦克风音量降低 + 麦克风音量增加 + 音量降低 + 音量增加 + 下一首 + 下一页 + 打开空调 + 原唱 + 暂停 + 播放 + 上一首 + 上一页 + 静音 + 恢复声音 + 重唱 + 选择序号 + 设置音量为%s + 音调降低 + 音调升高 + 置顶歌曲 + "你可以说:“点一首周杰伦的七里香” +                     “点一首七里香”" + 云端服务异常,请检查网络设置: %s + 我没听清,再说一遍吧 + 控制空调 + "1. 打开空调 + +2. 关闭空调" + 您可以这么和我说: + 点歌 + "1. 点一首【某位歌手】的【歌名】 + (例如:点一首 “周华健” 的 “朋友”) + +2. 点一首【歌名】 + (例如:点一首 “朋友”)" + 播放控制 + "1. 下一首 / 切歌 + +2. 播放 / 暂停 + +3. 重唱 + +4. 原唱 / 切原唱 + (当前歌曲将自动切换为原唱) + +5. 伴唱 / 切伴唱 + (当前歌曲将自动切换为伴唱)" + 呼叫服务 + "1. 呼叫服务生 + +2. 帮我拿【数量】【杯子/酒杯/红酒杯/高脚杯等】 + (例如:帮我拿三个酒杯)" + 音量调节 + "1. 声音大一点 / 大点声 / 音量加 + +2. 声音小一点 / 小点声/ 音量减 + +3. 静音 + +4. 恢复声音 / 声音正常 + +5. 麦克风音量大一点 + +6. 麦克风音量小一点" + 唤醒小爱 + "Hi,我是小爱同学,您的语音助手。 + +您可以对话筒说【小爱同学】唤起我,也可以通过按下话筒的【AI】按键唤起我。" + 网络异常,请检查当前网络设置 + 我在听…… + 包厢:%1$s + 微信点单 + 微信扫码,手机点单 + 微信点歌 + 微信扫码,手机点歌 + 微信扫码,分享作品 + 手机传歌 + 微信扫码,手机传歌 + 未连接到服务器 + 主演 + 导演 + 类型 + 简介 + 网络连接异常,请检查网络设置 + 有线 + 网络设置 + 无网络 + 未知 + WiFi + 下次吧 + 立即升级 + 最新版本: + 发现新版本数据库,升级后可以点播更多最新歌曲,可能花费较长时间,是否升级? + 新版本数据库 + + 去清理 + 下载 + 全部暂停 + 全部开始 + 已点 + 先唱完已点播的歌曲吧~ + 已唱 + 正在播放 + 处理中… + 输入首字母搜索 + 输入关键字搜索 + 应用 + 派对 + 收藏 + 广场舞 + 各国歌曲 + 启动 + 主页 + 电影 + 电影详情 + 无线网络 + 有线网络 + 分类 + 排行榜 + 常唱 + 猜你想唱 + 屏幕调节 + 快搜 + 设置 + 关于 + 关于本机 + 通用设置 + 数据设置 + 外设设置 + 显示设置 + 下载设置 + 网络设置 + 播放设置 + 设置 + 界面设置 + 歌星 + 皮肤中心 + 歌名 + 儿歌 + Disco + 戏曲 + 高清 + 热歌 + 本地 + 新歌 + 经典老歌 + 光辉历程 + 曲风 + 抖音排行榜 + U盘播放 + 大厅转播 + 您的欢唱时间快到了哦!请扫码充值 + "充值即享 +全年欢唱" + 退出应用 + 前往设置 + 因为您选择了【不再提醒授权】,所以需要手动开启APP必要权限: + "1. 进入应用设置页面,然后点击【权限】; +2. 开启【存储空间】、【麦克风】权限; +3. 返回桌面,重新打开点歌软件。" + 未授予必要的权限 + 按确认键可以打开控制台 + 已添加到下载列表,当前未开启边下边播,您可以在设置中启用 + 下载失败,请重试或切歌,您也可以暂时关闭边下边播 + 下载失败,下载次数已用完,请充值续费或切歌 + 下载失败,会员已过期,请充值续费或切歌 + 下载失败,网络异常,请检查网络连接或切歌,您也可以暂时关闭边下边播 + 下载失败,设备未激活,请激活设备或切歌 + 下载失败,存储空间不足,请清理文件、接U盘后重试或切歌 + 播放异常,即将自动切歌 + 边下边播中的歌曲不可暂停 + 定制播放器 + 私有播放器 + 系统播放器 + 通用播放器 + 密码不能为空! + 密码错误!还剩下%1$d次机会 + 您已多次输入密码错误,无法继续尝试! + 密码 + 请输入密码 + 输入歌名、歌星名点歌 + 打乱 + 微信扫码领取 + +%1$d首 + 无限 + +%1$d个月 + 会员服务协议 + 剩余下载数:%1$d + 下载流量用完,请充值 + (即将过期) + 会员权益:%1$s + 礼品码兑换 + 礼品码兑换失败,请检查礼品码是否正确 + 请输入礼品码 + 会员下载特权:已激活 + 会员有效期:%1$s + %1$s 到期 + 下载特权 + 剩余下载次数: %1$d + 剩余下载次数:已用完,请充值 + 歌曲下载特权: %1$d 首/月 + 会员有效期至: %1$s + 会员有效期至:已过期,请充值 + 已下载次数: %1$d + %1$d个月 + 支付遇到问题请点击二维码刷新 + 会员有效期至: + 限时 + 遥控器按返回或点击空白区域关闭弹窗 + 微信/支付宝扫码 + "获取分享二维码失败 +请检查网络连接" + "微信扫码 +立即分享作品" + APP即将重启以切换到LAN模式 + APP即将重启以切换到NAS模式 + APP即将重启以切换到单机模式 + 要酒杯 + 要筛盅 + 话筒异常 + 结账 + 催单 + 呼叫中 + 服务 + 呼叫服务生 + 当前包厢未开台,请先开台! + 扫码激活 + 最强擂主 + x%1$d + GOOD + GREAT + PERFECT + "无法获取用户信息 +立即扫码领取成绩" + S + 您也可以在已唱列表分享该作品 + 跳过 + 演唱歌曲: + 恭喜你成为本首歌的 + 的人 + 击败全国 + 演唱得分: + %1$d分 + "微信扫码 +分享作品" + 搜索 + 请输入搜索内容 + Search + seek_cost + seek_load_cost + 全选 + 查看 + 关于我们 + 和音元视智能语音点播系统 + 详情 + 语音功能尚未初始化,请稍后再试 + 语音功能 + 语音引擎音量 + AI 灯光 + 中控IP:%1$s + 中控MAC:%1$s + 未连接中控! + 重新连接 + "中控重连失败,错误信息: +" + 中控重连成功! + AI灯光初始化:%1$b + 设置语音类型 + 修改 + 伴唱 + 声音设置 + 设置语音功能和点歌默认音量 + 高级音效 + 跟随上首原伴唱 + 跟随上首音量 + 固定点播原伴唱 + 固定点播音量 + 跟随上首点播原伴唱 + 跟随上首点播歌曲音量 + 点播歌曲原伴唱 + 切到点播歌曲时原伴唱: + 切到点播歌曲时音量: + 点播歌曲音量 + 原唱 + 原伴唱 + 音量 + 硬盘读写权限 + 开启读写权限 + 未选择任何硬盘 + 开启读写权限失败!%1$s + 开启硬盘读写权限成功后,应用会自动重启。 + 未检测到硬盘 + 开启读写权限中…… + 去修复 + 开启读写权限成功,应用即将自动重启! + 轮播图配置 + 至少要选择一张轮播图 + 开机静态图 + 运行向导 + 打开 + 麦动 + 扫描本地文件 + 还原 + 保存 + 设置 + 同步 + 开始校准 + 设置语言 + 查看存储 + K歌系统 + 清理应用数据 + 清理 + 清理后,应用会自动重启,确认清理吗? + 包括缓存,本地存储的皮肤包等数据 + 开机清空下载列表 + 清空常唱 + 清空 + 您确认要清空常唱页面下的歌曲记录吗? + 清空常唱失败,请重试,%1$s + 已为您清空常唱页面下的歌曲记录。 + 清空常唱 + 熄屏 + 数据设置 + 前往设置 + 数据库、歌曲下载、歌曲文件相关设置 + "本地:%1$s +总数:%2$s" + 曲库 + 更新 + "全屏播放时曲库会在后台自动更新 +您也可以点击【更新】按钮手动更新" + 版本:%1$s + 设备信息 + 保存后将自动获取 + 触摸屏旋转 + 获取触摸屏旋转设置失败! + 重启设备后设置才会生效,是否重启? + 横屏 + 横屏翻转180° + 竖屏翻转180° + 竖屏 + 未修改任何参数! + 设置触摸屏旋转参数失败! + 双语 + 双屏异显 + 遥控触发开关双屏异显,即将重启 + 关闭双屏异显后,两个屏幕显示相同内容 + 下载设置 + 下载目录 + 设置 + 设置下载歌曲文件的存储设备 + 前往设置 + 下载存储设备和自动删歌等设置 + "设置 %1$s 为下载歌曲存储设备失败! +%2$s" + 设置 %1$s 为下载歌曲存储设备成功! + 自定义音效 + 自定义音效设置失败,请稍后再试! + 自定义音效设置成功 + 恢复默认音效失败! + 已恢复为默认音效 + 修改音效失败,请稍后再试! + 切换音效中:%1$s + 切换音效中 + 修改音效成功 + 蓝调 + 古典 + 乡村 + 电子乐 + 人声 + 爵士 + 摇滚 + 标准 + 有线网络 + 请检查网线是否插好 + 已生效 + 悬浮按钮 + 隐藏/显示系统悬浮按钮 + 监测到数据已还原,请重新扫描本地文件,以正常使用 + 自动全屏 + 30秒 + 45秒 + 1分钟 + 2分钟 + 关闭(不推荐) + 设置间隔 + 设置多久未操作后自动进入全屏 + HDMI声音 + 巨幕设置 + 巨幕服务器IP + 巨幕IP无效! + 巨幕IP未修改 + 巨幕IP设置成功 + 设备信息 + MAC:%1$s + 设备:%1$s + SN:%1$s + 版本:%1$s + 本机存储 + 输入不能为空 + 请输入正确的格式 + 自动获取 + DNS服务器1 + DNS服务器2 + 默认网关 + IP地址 + 子网掩码 + 静态设置 + 密码 + 语言 + "修改后语言后,应用将会自动重启。 +请选择要使用的语言:" + 语言已修改,应用即将自动重启 + 请选择语言 + 本地文件 + 如果您是首次扫描该设备可能会花费较长时间,约%1$s,过程中将暂停播放,确认现在扫描吗? + 请选择要扫描的硬盘! + 硬盘有歌,搜索不出来?扫描硬盘,更新本地数据库 + 基础指令 + 门牌灯 + 灯光 + 音效 + 中控测试 + "当前指令:%1$s +码值:%2$s +中控显示值:%3$s" + 走马灯 + 点击此处输入走马灯内容 + 设置视频窗口固定走马灯内容 + 歌曲最大音量 + 设置密码(542317) + 开启设置密码后,进入设置时需要输入密码:542317 + 网络检测 + 语音助手检测 + 检测 + CDN检测 + DNS解析检测 + 重新诊断 + 本地IP地址检测 + 异常 + 正常 + 检测中 + 未检测 + 网络连接检测 + 云曲库检测 + 云平台检测 + 开始诊断 + 正在诊断 + 获取曲库下载链接失败,请配置网络,改为静态设置 + 连接云服务失败,请配置网络,改为静态设置 + 语音助手连接失败,在线语音识别暂时不可用,我们会尽快处理 + 网络连接不可用,请检查网络连接情况 + 获取曲库下载链接失败,请配置网络,将DNS改为114.114.114.114或8.8.8.8 + 连接云服务失败,请配置网络,将DNS改为114.114.114.114或8.8.8.8 + 网络连接正常,祝您使用愉快 + 长连接检测 + 点播默认音量 + 边下边播 + 在后台播放 + 播放器、视频窗口相关设置 + 播放器切换 + 切换播放器类型,选择合适的播放器 + 关机和重启密码(542317) + 关机 + 确定要关机? + 点歌二维码 + 是否在播放窗口上显示手机点歌二维码 + 屏幕旋转策略 + 横屏<—>竖屏翻转180° + 横屏<—>竖屏 + 横屏翻转180°<—>竖屏翻转180° + 横屏翻转180°<—>竖屏 + 重启应用才能生效,是否重启? + 屏幕旋转将按照配置的两个方向进行 + 重启 + 数据还原 + 删除本地数据库后,APP会重启并还原,确认现在还原吗? + 删除数据库失败,请重试,%1$s + 正在删除数据库… + 删除数据库成功,将在%1$d秒后重启… + 删除本地数据库,APP会重新初始化,不会删除歌曲文件。 + 立即重启 + 确认关台吗? + 关台成功 + 请输入开台分钟数,默认无期限 + 重置成功 + 超过24小时,请重新输入 + 重置为开台状态 + 重置 + 硬盘存储空间 + 开始扫描磁盘 + 扫描磁盘失败,请重试 + 歌曲评分 + 歌曲评分模式 + 修改评分模式成功! + 关闭评分 + 娱乐模式 + 专业模式 + 设置评分条显示在触摸屏上 + 关闭时,评分条将显示在电视屏上 + 评分为体验功能,需要联网使用 + 电视屏幕调节 + 位置 + 还原 + 缩放 + 设置 + 调节大屏显示位置、大小 + 未检测到大屏连接 + 皮肤中心 + 前往设置 + 歌曲片头字幕 + 自动删歌 + GB + 预留空间: + 设置范围: %1$s  -  %2$s + 预留空间设置过大,可能导致歌曲文件无法存储 + 预留空间设置过小,可能导致无法正常播放 + 预留存储空间 + 设置预留的存储空间大小 + 预留空间大小不能为空! + 请输入正确的格式 + 当点歌机可用存储空间不足时,请打开自动删歌按钮,我们将自动删除部份本地冷门歌曲,腾出空间。 + 修改后的值不能和当前大小一样! + 当剩余存储空间低于“预留存储空间”时,自动删除冷门的歌曲 + 开启自动删歌 + 开启【自动删歌】功能失败 + 已开启【自动删歌】功能 + "下载失败,存储空间不足,请尝试如下操作: +1. 在点歌机上接入U盘或移动硬盘。 +2. 开启【自动删歌】功能。" + "下载失败,自动删歌后存储空间仍然不足,请尝试如下操作: +1. 在点歌机上接入U盘或移动硬盘。 +2. 删除不需要的文件或歌曲。 +3. 确认【数据设置】-【预留存储空间】是否设置过大" + 修改失败,请重试,%1$s + 修改成功 + 门店管理 + 修改成功,重启设备后生效,是否立即重启? + 修改成功,重启应用后生效,是否立即重启? + 更新 + 同步云端数据库可能会花费较长时间,过程中将暂停播放,确认现在同步吗? + 开始更新曲库 + 更新曲库失败,请重试 + 找不到最新歌曲?同步云端数据库 + 恢复出厂设置 + 系统设置 + 超市入口开关 + 触摸屏校准 + TV输出模式 + 设置 + 正在切换输出模式 + 设置TV输出模式、分辨率、刷新频率 + U盘加歌 + 立即加歌 + 开始扫描 + 未找到U盘或U盘尚未准备好,请接入U盘或稍后重试 + 免责声明 + 格式说明 + 注意:请确保U盘中文件命名为 歌星_歌名_语种_原唱音轨_伴唱音轨_分类.后缀 + 正在进行U盘加歌 + 选择设备 + 请选择要的扫描的设备! + 开始扫描U盘 + U盘加歌失败,请重试 + 扫描U盘内按规定命名的歌曲文件,并添加到曲库中 + 显示U盘加歌歌曲 + 是否隐藏U盘加歌歌曲? + 是否显示U盘加歌歌曲? + 处理中,请稍后 + 处理完成 + 关闭后,U盘加歌歌曲将隐藏 + 重置数据库 + 重置 + 升级过程中会下载最新的数据库并重新扫描存储设备,可能花费较长时间,确认现在升级本地数据库吗? + 升级数据库失败,请重试,%1$s + 正在升级数据库… + 升级数据库成功! + 修复数据异常,下载最新数据库并备份 + 视频比例 + 16 : 9 + 4 : 3 + 全屏 + 设置 + 设置触摸屏上视频窗口全屏时的画面比例 + 音量平衡 + 根据歌曲音量大小自动调节音量,保持实际音量大小稳定 + ****** + 关闭 + 关闭WiFi中 + 连接 + 连接失败,请确认输入密码是否正常 + 已连接 + 连接中 + 删除成功 + 要查看可用网络,请打开WLAN。 + 无线网络不可用,请拔掉网线后再试。 + 身份验证出现问题 + 信号强度:%1$s + 附近未搜索到任何WiFi + (未更改) + 开启 + 打开WiFi中 + 设置 + WiFi密码不能为空 + 请输入WiFi密码 + 刷新 + 获取WiFi列表中 + 已保存 + 开关 + 分享 + n2.4.2 + Info + 您正在使用盗版软件,为了您的安全,APP禁止使用 + 您正在使用盗版软件,为了您的安全,APP即将在 %1$d 秒后退出… + %1$s 来啦? + %1$s 亲临现场啦! + 你就是 %1$s + 唱得太棒了 + 完美! + 唱得太好了 + 如此动听 + 声音相似度 + 全部 + 中国 + 外国 + 港台 + 日本 + 韩国 + 大陆 + 马来 + 缅甸 + 泰国 + 越南 + 欧美 + 佚名 + + + + 组合 + 未知 + 切换皮肤失败,请重试 + 使用该皮肤需要重启应用才能生效,是否立即重启? + 立即使用 + 使用中 + 暂无皮肤包可切换 + 正在切换皮肤… + 蓝紫风情 + 电音舞台 + 迷幻之光 + 默认风格 + 流金岁月 + 经典黑 + 点击图片预览 + 皮肤包已更新,即将重启应用 + 删除 + 删除文件 + 收藏 + 已收藏 + 取消收藏成功 + 收藏成功 + 录音 + 置顶 + 全网 + 本地 + 常唱 + 全部 + %1$d次 + 近期 + 确认删除《%1$s》这首歌的资源文件吗? + 删除歌曲《%1$s》文件失败,请重试,%2$s + 正在删除歌曲文件… + 文件不存在!您可以重新导入该歌曲文件,也可以删除该歌曲 + 全部 + 缅甸语 + 柬埔寨语 + 国语 + 英语 + 法语 + 印度语 + 印尼语 + 日语 + 高棉语 + 韩语 + 老挝语 + 马来语 + 蒙古语 + 闽南语 + 其它 + 菲律宾语 + 俄语 + 泰语 + U盘加歌 + 越南语 + 粤语 + 未查找到歌词 + 歌名: + 未能找到歌曲:%1$s + 未能找到歌曲 + 猜你想唱 + 一键点歌(%1$d首) + 您共唱过 + + 抖音榜 + 热门榜 + 新歌榜 + 人气热歌 + 飙升榜 + 打开应用主动弹出猜你想唱歌曲 + 歌手: + 冠名 + 暂无用户 + 本首歌曲 + 快来成为我的第一次吧! + 独家冠名 + 本首歌曲由 + 微信扫码,为歌曲冠名 + 混响 + 音调 + 女调 + 男调 + 原调 + 音量 + 正在释放数据库文件 %1$d%% + 正在释放资源文件 %1$d%% + 999+ + 开机静态图已更新,设备即将自动重启!再次重启后生效! + 开机动画已更新,设备即将自动重启! + 酒水点单服务暂时无法使用 + 酒水点单服务暂时无法使用,请确保收银机已成功配置 + 首拼 + 手写 + 全拼 + 报台 + 请输入台号 + 台号必须为 1~999 之间! +   请 [%1$s] 号做好准备 + 下一首 + %1$s 号台点歌成功 + 下面请【%1$s】号做好准备 + 已关闭报台功能 + 开启报台 + 已开启报台功能! + 设置报台功能失败 + 未修改任何报台配置! + 语音开关 + 歌曲已被点播,请选择其他歌曲 + %1$s号 + 下面请%1$s号做好准备 + tcp_speed + 有效期至:%1$s + 测试版本 + 升降调 + 麦克风 + 诵读 + 演唱 + KTV + 抒情 + 影视 + 流行 + 专业 + 摇滚 + 唱将 + 智能灯光关 + 智能灯光开 + 话筒 + 混响 + 静音 + 音调 - + 女调 + 音调 + + 男调 + 标准音调 + 音量 + 退出U盘播放模式? + 上一级 + 小时 + 分钟 + + 升级成功! + 网络异常,下载升级包失败! + 升级包大小:%1$s + 下载中...... + 升级失败,错误信息:%1$s + 退出应用 + 立即安装 + 升级包安装中...... + 升级包校验出错! + 发现新版本 + 发现新版本 - + 网络异常,尝试重新下载,重试次数: + 开始升级 + 未发现U盘,请检查是否有外接设备接入 + 详情 + 用户协议 + 您当前选择不同意协议,将无法继续使用该软件 + 立即查看 > + "没找到想要的《%1$s》? +来试试制作自己的伴奏带!" + "微信扫码 +制作我的伴奏带" + v-cache + vdec + 大厅转播 + +新增 + 新增节目 + 删除 + 确认删除该转播节目? + 当前没有转播节目 + 名称 + 链接 + 节目列表 + 正在转播节目,无法执行该操作! + 大厅转播配置 + 添加、删除大厅转播节目 + 开始转播 + 停止转播 + 返回 + 退出 + A-Z + 热门 + 麦克风 + 音乐 + 静音 + 放音 + 再等等 + 良好 + 极佳 + 一般 + 较弱 + Wi-Fi名称: + Wi-Fi密码: + 字数 + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..db10a65 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +